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..378173e6bd --- /dev/null +++ b/apps/web/core/claims/browse/activity-row-tag.tsx @@ -0,0 +1,33 @@ +import cx from 'classnames'; + +const LABELS = { debate: 'Debate', claim: 'Claim', comment: 'Comment' } as const; + +/** + * What kind of thing a row is, said in a word. + * + * The feed draws three kinds of row that carry the same furniture — a face or a keyframe, a name, a + * sentence, a vote, a comment count — and mean different things. 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. + * + * All three go through here. The debate row used to spell its own chip inline with different padding + * and a different type size, so the one label a reader was meant to compare across rows was the one + * thing that did not match between them. + * + * 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: keyof typeof LABELS }) { + return ( + + {LABELS[kind]} + + ); +} 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..f80962b1d9 --- /dev/null +++ b/apps/web/core/claims/browse/claim-activity-count.test.ts @@ -0,0 +1,251 @@ +import { QueryClient } from '@tanstack/react-query'; + +import { describe, expect, it } from 'vitest'; + +import { + adjustClaimActivityTotal, + claimActivityCountsQueryKey, + decodeClaimActivityCounts, +} from './claim-activity-count'; +import type { ClaimActivityCount } from './claim-activity-fields'; + +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 }); + }); + + // 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); + }); +}); + +/** + * 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); + }); + + /** + * 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 } } }); + + 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 new file mode 100644 index 0000000000..7bda8d09b2 --- /dev/null +++ b/apps/web/core/claims/browse/claim-activity-count.ts @@ -0,0 +1,193 @@ +'use client'; + +import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { type QueryClient, useQuery } from '@tanstack/react-query'; + +import * as React from 'react'; + +import { Effect } from 'effect'; +import { parse } from 'graphql'; + +import { uuidToHex } from '~/core/id/normalize'; +import { graphql } from '~/core/io/graphql-client'; + +import { CLAIM_ACTIVITY_COUNT_FIELDS, type ClaimActivityCount, countActivityForNode } from './claim-activity-fields'; + +// Deliberately not re-exported from here. `claim-activity-fields` is a server-safe module on +// purpose: the explore feed assembles its card selection on the server, and importing these through +// this `'use client'` module turns them into client-reference proxies at build time — the fields +// interpolate to nothing and the query fails to parse. A convenience re-export is a working import +// path to that failure, so the split is only real if this file does not offer one. Import 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. + * + * 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!]) { + entities(filter: { id: { in: $ids } }) { + id + ${CLAIM_ACTIVITY_COUNT_FIELDS} + } + } +`; + +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 +): { + 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, error, refetch } = useQuery({ + queryKey: claimActivityCountsQueryKey(ids), + queryFn: ({ signal }) => + Effect.runPromise( + graphql({ + query: claimActivityCountsDocument, + decoder: decodeClaimActivityCounts, + variables: { ids }, + signal, + }) + ), + enabled: enabled && ids.length > 0, + staleTime: 60_000, + // 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, + }); + + const retry = React.useCallback(() => void refetch(), [refetch]); + + return React.useMemo( + () => ({ counts: data ?? EMPTY_COUNTS, error: (error as Error | null) ?? null, retry }), + [data, error, retry] + ); +} + +/** + * 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 — 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 + * 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 async function adjustClaimActivityTotal( + queryClient: QueryClient, + claimId: string, + delta: number +): Promise { + if (delta === 0) return; + const id = uuidToHex(claimId); + + // 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[]; state: { data?: unknown } }) => { + const ids = query.queryKey[1]; + if (!Array.isArray(ids) || !ids.includes(id)) return false; + return (query.state.data as Map | undefined)?.get(id) != null; + }, + }; + + await queryClient.cancelQueries(holdsABaseline); + + 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 + // 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-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..209f746bc9 --- /dev/null +++ b/apps/web/core/claims/browse/claim-activity-depth.ts @@ -0,0 +1,41 @@ +/** + * 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. */ +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/claim-activity-fields.ts b/apps/web/core/claims/browse/claim-activity-fields.ts new file mode 100644 index 0000000000..ea6044dfa1 --- /dev/null +++ b/apps/web/core/claims/browse/claim-activity-fields.ts @@ -0,0 +1,176 @@ +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 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. + */ + +/** + * ## Two ways this can disagree with the rows, both latent + * + * The feed draws debates scoped to the space the claim is being read through, and at most + * `ACTIVITY_DEBATE_LIMIT` of them. This counts every debate on the claim, in any space. So a claim + * with a debate published into another space, or with more than ten of them, would print a heading + * larger than the rows below it. + * + * Measured rather than assumed, on testnet 2026-09-25: across the 25 busiest claims, **no** debate + * sits outside its claim's display space, and the most debates on any one claim is five. Neither + * case exists yet. + * + * Left unscoped on purpose. The number's job is that the claim card in Explore and the claim page + * agree, and the card's selection is one string applied to a page of cards from many spaces — there + * is no space id to interpolate. Scoping this would fix the page and leave the card wrong, so the + * two would disagree precisely when the count is wrong today. Fixing it properly means either a + * per-space card query or moving the count server-side; both are their own change, and both need a + * decision about whether a claim's activity is a per-space or a global number. + */ + +/** + * 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}"] } } + } + ) { + fromEntity { + id + 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 { + 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; + + // 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.values()) { + debateComments += commentsOf(debate); + 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.size, + extractedClaims, + debateComments, + total: claimComments + debates.size + extractedClaims + debateComments, + }; +} 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..ab2468d5f3 --- /dev/null +++ b/apps/web/core/claims/browse/claim-activity-order.test.ts @@ -0,0 +1,394 @@ +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, orderNewestFirst } from './claim-activity-order'; +import { debateCreatedDate, debateDate } from './claim-debates'; + +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']); + }); + + /** + * 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 }, + 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'); + + 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); + }); +}); + +describe('rows nothing can place in time', () => { + const dated = (id: string, createdAt: string) => ({ id, createdAt }); + const undated = (id: string) => ({ id, createdAt: '' }); + + // The bug this closes: `activityTime` answers -Infinity, the direction multiplies it, and under + // 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' + ); + + 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' + ); + + 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' + ); + + expect(merged.map(entry => entry.row.id)).toEqual(['dated', 'a', 'b']); + }); + + it('still sorts undated rows behind dated ones when Best has no scores to go on', () => { + const merged = mergeActivityRows([], [undated('no-date'), dated('dated', '2026-01-01T00:00:00Z')], 'best'); + + 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']); + }); +}); + +/** + * 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-activity-order.ts b/apps/web/core/claims/browse/claim-activity-order.ts new file mode 100644 index 0000000000..7581c8822e --- /dev/null +++ b/apps/web/core/claims/browse/claim-activity-order.ts @@ -0,0 +1,188 @@ +import type { ClaimTiming } from '~/core/debates/claim-timing'; +import type { TranscriptClaim } from '~/core/debates/transcript-claims'; +import { uuidToHex } from '~/core/id/normalize'; + +/** + * 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; +}; + +/** 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. + * + * An unparseable or missing timestamp answers `-Infinity`, which is a marker rather than a position: + * `new Date(undefined).getTime()` is `NaN`, and NaN makes every comparison false, leaving a sort + * silently unstable rather than merely wrong. + * + * It is not enough on its own, which this comment used to claim. `-Infinity` puts an undated row + * last under newest-first and *first* under oldest-first, because the direction multiplies it — so + * a debate with no date led the Old order. And two undated rows subtract to `NaN`, which is the + * unstable comparator the sentinel was introduced to avoid. {@link compareByTime} handles the marker + * before any arithmetic touches it. + */ +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. + * + * `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( + comments: C[], + extras: E[], + order: 'best' | 'top' | 'newest' | 'oldest', + scoreFor: ActivityScoreLookup = NO_SCORES, + pinnedIds?: ReadonlySet +): Array<{ kind: 'comment'; row: C } | { kind: 'extra'; row: E }> { + 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 [...pinned, ...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)) || compareByTime(a.row, b.row, -1)); + + return [...pinned, ...merged]; +} + +/** + * Chronological order with undated rows kept at the tail, whichever way time is running. + * + * The undated case is settled before any subtraction happens, for two reasons. Multiplying the + * sentinel by the direction sorts it to whichever end the direction points at, so a row with no date + * led the Old order — a row nothing can place is not the oldest thing that ever happened any more + * than it is the newest. And `-Infinity - -Infinity` is `NaN`, so two undated rows returned a + * comparator result that leaves the sort undefined. + * + * @param direction -1 for newest first, 1 for oldest first. It applies only to rows that have a date. + */ +function compareByTime(a: ActivityOrderable, b: ActivityOrderable, direction: -1 | 1): number { + const aTime = activityTime(a); + const bTime = activityTime(b); + const aDated = Number.isFinite(aTime); + const bDated = Number.isFinite(bTime); + + // Equally undated: no opinion, so the stable sort keeps the order the lists arrived in. + if (!aDated || !bDated) return aDated === bDated ? 0 : aDated ? -1 : 1; + + return direction * (aTime - bTime); +} + +/** 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; +} + +/** + * 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-comment-position.test.tsx b/apps/web/core/claims/browse/claim-comment-position.test.tsx index 20550376c9..e23e3f7e21 100644 --- a/apps/web/core/claims/browse/claim-comment-position.test.tsx +++ b/apps/web/core/claims/browse/claim-comment-position.test.tsx @@ -78,6 +78,7 @@ describe('claim comment position badges', () => { viewerDirection={null} viewerSpaceId={null} isViewerResponseLoading={false} + claimName={null} > @@ -87,6 +88,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 }]) @@ -102,6 +172,7 @@ describe('claim comment position badges', () => { viewerDirection="positive" viewerSpaceId="viewer-space" isViewerResponseLoading + claimName={null} > @@ -127,6 +198,7 @@ describe('claim comment position badges', () => { viewerDirection={null} viewerSpaceId="viewer-space" isViewerResponseLoading + claimName={null} > @@ -151,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 2438488f28..19af192345 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, viewerDirection, viewerSpaceId, isViewerResponseLoading, @@ -80,6 +89,15 @@ export function ClaimCommentPositionProvider({ entityId: string; spaceId: string; responseKind: DebateResponseKind; + /** + * 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. */ @@ -108,27 +126,73 @@ 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}; } -/** 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. + * + * 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. + * + * It used to take a response kind and read Agree/Disagree or Verify/Dispute from it. #2541 answered + * every claim with Agree/Disagree and removed the `veracity` kind outright, so there is one + * vocabulary and `responsePositionLabel` needs only the side. + */ +export function ResponsePositionTag({ + position, + title, +}: { + position: boolean; + /** Says what the position is *on*, which the word alone cannot. */ + title?: string; +}) { return ( - {responsePositionLabel(positive)} + {responsePositionLabel(position)} ); } + +/** + * Where this person stands on the claim their comment hangs under. + * + * "The claim their comment hangs under" rather than "the claim at the top of the page", and the + * difference is not pedantic. A comment on an extracted claim sits directly below that claim's + * sentence, so a bare "Agree" beside the author's name reads as agreement with *that* — and the page + * claim it actually reported can be the opposite side, several screens up. Observed on testnet: a + * comment arguing against an extracted claim, badged "Agree", because its author agrees with the + * page claim the debate was about. + * + * The rule is now one sentence with no special cases: the badge reports the nearest claim above the + * comment. Under an extracted claim that is the extracted claim; under a debate, or at the top of + * the thread, there is no nearer claim and it stays the page's. Nesting the provider is all it takes, + * because the badge reads whichever one is closest. + * + * Nothing is borrowed from further away when the person holds no position on the nearer claim: an + * absent badge says less than a wrong one. + */ +export function ClaimCommentPositionBadge({ authorSpaceId }: { authorSpaceId: string }) { + const context = React.useContext(ClaimCommentPositionContext); + const direction = context?.directions.get(uuidToHex(authorSpaceId)); + if (!context || !direction) return null; + + const label = responsePositionLabel(direction === 'positive'); + + return ( + + ); +} diff --git a/apps/web/core/claims/browse/claim-debates.tsx b/apps/web/core/claims/browse/claim-debates.tsx index 066e8da22d..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}. */ -function debateDate(debate: Entity): Date | null { - const raw = debate.createdAt ?? debate.updatedAt; +export function debateDate(debate: Entity): Date | null { + 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/claim-page-view.test.tsx b/apps/web/core/claims/browse/claim-page-view.test.tsx index fdf8f015db..d4623d082c 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, fireEvent, render as renderBare, screen } from '@testing-library/react'; import type React from 'react'; @@ -11,8 +12,40 @@ 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(() => ({ + activityError: null as Error | null, + retryActivity: vi.fn(), + activityCountError: null as Error | null, + 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, + /** 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. */ @@ -72,7 +105,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 })); @@ -117,6 +150,41 @@ 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, + 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 +// 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); + return { + counts: new Map(mocks.activityTotal == null ? [] : [['claim1', { total: mocks.activityTotal }]]), + error: mocks.activityCountError, + retry: mocks.retryActivityCount, + }; + }, + adjustClaimActivityTotal: mocks.adjustActivityTotal, +})); + vi.mock('~/core/debates/hooks', () => ({ useDebateClaims: () => ({ data: { claims: [] } }), })); @@ -213,7 +281,22 @@ vi.mock('~/partials/profile/profile-activity-section', () => ({ })); vi.mock('~/partials/editor/editor', () => ({ Editor: () =>
})); vi.mock('~/partials/comments/comments-section', () => ({ - CommentSection: () =>
, + CommentSection: ({ + title, + activityRows, + totalOverride, + }: { + title?: string; + activityRows?: Array<{ id: string }>; + totalOverride?: number; + }) => ( +
r.id).join(',')} + /> + ), })); function claimEntity(description: string | null) { @@ -227,6 +310,17 @@ function claimEntity(description: string | null) { } beforeEach(() => { + mocks.activityRows = []; + mocks.activityError = null; + mocks.retryActivity.mockClear(); + mocks.activityCountError = null; + 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; mocks.summaryLoading = false; mocks.hasCounts = true; @@ -481,6 +575,45 @@ describe('ClaimPageView record', () => { expect(setActiveSystemTab).toHaveBeenNthCalledWith(2, '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'); + }); + + 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(['debates', 'claims']); + }); + it('marks only failed record counts unavailable in Activity', () => { mocks.record.claimsError = true; mocks.record.claimsCountUnavailable = true; @@ -591,6 +724,117 @@ 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. + */ + /** + * `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(); + }); + + /** + * 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. + */ + /** + * 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. + */ + /** + * 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(); + + 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'); + + 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(); + }); + + /** + * 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'); + + 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(); + + expect(screen.queryByText(/Couldn’t load the debates/)).not.toBeInTheDocument(); + }); + + 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 60f2fc7276..a39ffb36c9 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'; @@ -15,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'; @@ -32,6 +35,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 { ENTITY_PAGE_CONTENT_ANCHOR, entityPageTitleAnchor } from '~/partials/entity-page/entity-page-anchors'; @@ -45,11 +49,13 @@ 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 { adjustClaimActivityTotal, useClaimActivityCounts } from './claim-activity-count'; import { ClaimEndSlot } from './claim-end-slot'; 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'; @@ -342,6 +348,7 @@ export function ClaimPageView({ activeTab={requestedTab} entityId={entityId} spaceId={spaceId} + claimName={entity.name ?? null} entityRelations={entity.relations} responseKind={responseKind} summary={summary} @@ -361,6 +368,7 @@ function ClaimTabPanel({ activeTab, entityId, spaceId, + claimName, entityRelations, responseKind, summary, @@ -373,6 +381,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']; @@ -418,8 +428,107 @@ function ClaimTabPanel({ return ; } + return ( + + ); +} + +/** + * The claim's overview: the related-claims gallery, then everything that has happened to it. + * + * Debates appear twice on purpose, and this comment used to say the opposite. An earlier cut did + * remove the gallery — a debate belongs in the account of what happened to this claim rather than in + * a shelf above it — and it went back in because the two are not the same offer: the gallery is the + * way through to the Debates tab, the complete filterable index, while the thread shows the few most + * recent in the order they happened, in among the comments. Two jobs. If that stops being true, the + * gallery is the one to drop. + * + * 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}. + * + * 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] + ); +} + +function ClaimOverviewTab({ + entityId, + spaceId, + claimName, + responseKind, + summary, + record, + hrefs, + onSelectSystemTab, +}: { + entityId: string; + spaceId: string; + /** For the hover title on every commenter's side badge — see `ClaimCommentPositionProvider`. */ + claimName: string | null; + responseKind: ClaimResponseState['responseKind']; + summary: ClaimResponseState['summary']; + record: ReturnType; + hrefs: { debates: string; claims: string }; + onSelectSystemTab?: (tab: ClaimSystemTab) => void; +}) { + // 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.counts.get(ID.uuidToHex(entityId))?.total; + + 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[] = [ { + // 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, @@ -472,11 +581,52 @@ function ClaimTabPanel({ 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} > - + {/* + 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 || 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. + )} +
+ )} + {/* "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. */} + ); @@ -533,6 +683,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. @@ -544,6 +697,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/claims/browse/debate-activity-row.test.tsx b/apps/web/core/claims/browse/debate-activity-row.test.tsx new file mode 100644 index 0000000000..e2700db443 --- /dev/null +++ b/apps/web/core/claims/browse/debate-activity-row.test.tsx @@ -0,0 +1,333 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import '@testing-library/jest-dom/vitest'; +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'; + +const mocks = vi.hoisted(() => ({ + commentQueries: [] as Array<{ entityId: string; enabled: boolean }>, + 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 +// about is what the row decides from its two counts: whether there is a branch under it at all. +vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ + EntityVoteButtons: () =>
, +})); + +vi.mock('~/partials/comments/entity-comments-button', () => ({ + EntityCommentsButton: ({ count }: { count: number }) => ( + + ), +})); + +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, + error: mocks.transcriptError, + retry: mocks.retryTranscript, + }), +})); + +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: mocks.debateComments, error: mocks.commentsError, refetch: mocks.refetchComments }; + }, +})); + +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; + mocks.transcriptError = null; + mocks.retryTranscript.mockClear(); + }); + 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(); + }); +}); + +/** + * 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(); + mocks.debateComments = []; + mocks.commentsError = null; + mocks.refetchComments.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 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(); + }); + + /** + * 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. + */ + 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', () => { + renderRow({ claimCount: 18, commentCount: 0 }); + + expect(screen.queryByText(/Couldn’t load the claims/)).not.toBeInTheDocument(); + 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 new file mode 100644 index 0000000000..725c784811 --- /dev/null +++ b/apps/web/core/claims/browse/debate-activity-row.tsx @@ -0,0 +1,468 @@ +'use client'; + +import * as React from 'react'; + +import cx from 'classnames'; + +import { useEntityCommentCounts } from '~/core/comments/use-entity-comment-counts'; +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'; +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 { 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'; +import { ACTIVITY_ROOT_DEPTH } from './claim-activity-depth'; +import { orderExtractedClaims, orderNewestFirst } from './claim-activity-order'; +import { DebateCommentRow } from './debate-comment-row'; +import { ExtractedClaimRow, type SpeakerProfile } from './extracted-claim-row'; + +/** + * 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; + /** 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; + /** Each debater and the side they argued, from `Supported by` / `Opposed by`. */ + sides: Array<{ spaceId: string; position: boolean }>; + /** 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; + /** + * Counted once for every debate on the page rather than per row. + * + * 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 | null; + claimCount: number | null; +}; + +/** + * 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. + */ +export function DebateActivityRow({ + debate, + spaceId, + profilesBySpaceId, + sides, + claimText, + keyframeUrl, + publishedAt, + commentCount, + claimCount, +}: DebateActivityRowProps) { + const [collapsed, setCollapsed] = React.useState(false); + const composer = useInlineComposer(); + // 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 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] + ); + // 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); + + // Below the thumbnail rather than `avatarBottomInRowPx`: this row's left column is a keyframe, not + // an avatar, so its own height is where the spine starts. + const spine = useThreadParentSpine(KEYFRAME_HEIGHT_PX + SPINE_START_GAP_PX); + + const branchLabel = { expand: 'Expand this debate', collapse: 'Collapse this debate' }; + + // 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. + // + // 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 + liveCommentCount > 0; + + return ( +
+ {hasBranch && !collapsed && ( + setCollapsed(true)} + /> + )} + +
+
+ {hasBranch && 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 && ( + + )} + + )} +
+ +
+ {/* + 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. + */} +
+ {debaterLine && ( + // The byline is the link, the way a commenter's name is. What the debate argued is + // this row's text, and text that navigates when you touch it is a trap in a thread + // where every neighbouring sentence is inert — a reader selecting a phrase to quote + // from a claim row does not expect the one below it to leave the page. + + {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. + + {getRelativeTime(publishedAt.toISOString())} + + )} +
+ + {headline} + +
+ {hasBranch && !collapsed && ( + setCollapsed(true)} + /> + )} + + {/* 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. */} + {/* 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} + + )} + + + Watch debate + +
+ + + + {hasBranch && !collapsed && ( +
+ setCollapsed(true)} + branchLabel={branchLabel} + commentCount={commentCount} + liveCommentCount={liveCommentCount} + /> +
+ )} +
+
+
+ ); +} + +/** + * Everything that hangs off a debate: the claims it produced, then the comments people left on it. + * + * 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 DebateBranch({ + debateId, + spaceId, + profilesBySpaceId, + positionBySpaceId, + onCollapse, + branchLabel, + commentCount, + liveCommentCount, +}: { + debateId: string; + spaceId: string; + profilesBySpaceId: Map; + /** Debater space id (canonical) → the side they argued in this debate. */ + positionBySpaceId: Map; + 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. */ + commentCount: number | null; + /** + * 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); + // 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, + error: commentsError, + refetch: refetchComments, + } = 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. 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 || liveCommentCount > 0, + }); + + const claimIds = React.useMemo(() => claims.all.map(claim => claim.id), [claims.all]); + const commentCounts = useEntityCommentCounts(claimIds); + + 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]); + // 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 + // 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 claimsInOrder = [...ordered.timed, ...ordered.untimed]; + + // The claims could not be read, so the branch owes the reader an account of what it is not showing. + // + // 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; + // 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 + orderedComments.length + (commentsFailed ? 1 : 0); + + if (rowCount === 0) return null; + + return ( + + {claimsFailed && ( + + Couldn’t load the claims from this debate. + + )} + + {claimsInOrder.map((claim, index) => { + const speakerSpaceId = speakerBySourceBlockId.get(uuidToHex(claim.blockId)) ?? null; + return ( + + + + ); + })} + + {commentsFailed && ( + + void refetchComments()}>Couldn’t load the comments on this debate. + + )} + + {orderedComments.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 new file mode 100644 index 0000000000..1dc07067b5 --- /dev/null +++ b/apps/web/core/claims/browse/debate-comment-row.test.tsx @@ -0,0 +1,282 @@ +import '@testing-library/jest-dom/vitest'; +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'; +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. */ + 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 +// 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/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), + // Visibility lives in the component, not in the row, so the stand-in has to honour it too. + InlineCommentComposer: ({ + composer, + targetEntityId, + ancestors, + }: { + composer: { isComposing: boolean }; + targetEntityId: string; + ancestors?: Array<{ id: string }>; + }) => + composer.isComposing ? ( +
ancestor.id).join(',')} + /> + ) : null, +})); +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} + ), +})); + +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, + }; +} + +// 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( + + ); +} + +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('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 offering to continue elsewhere would be saying the same thing twice. + expect(screen.queryByRole('link', { name: /Continue this thread/ })).not.toBeInTheDocument(); + }); + + // 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(); + const link = screen.getByRole('link', { name: 'Continue this thread — 1 more reply' }); + // This comment, in the space it lives in — not the debate it hangs under. Its own page roots the + // branch the reader was reading; the debate's reopens the whole thread one level up. + expect(link).toHaveAttribute('href', expect.stringContaining('comment-1')); + expect(link).toHaveAttribute('href', expect.stringContaining('author-space')); + 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) => + 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', () => { + renderRow(); + + 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 new file mode 100644 index 0000000000..fbdfdf929c --- /dev/null +++ b/apps/web/core/claims/browse/debate-comment-row.tsx @@ -0,0 +1,242 @@ +'use client'; + +import * as React from 'react'; + +import cx from 'classnames'; + +import { ClaimCommentPositionBadge } from '~/core/claims/browse/claim-comment-position'; +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 { PAGE_DENSITY, avatarBottomInRowPx, threadSpineOffsetPx } from '~/partials/comments/comment-density'; +import { getRelativeTime } from '~/partials/comments/comment-time'; +import { InlineCommentComposer, useInlineComposer } from '~/partials/comments/inline-comment-composer'; +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 { ThreadContinue, ThreadShowMore } from '~/partials/comments/thread-overflow'; +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'; +import { orderNewestFirst } from './claim-activity-order'; + +/** + * 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. + * + * 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 }> = []; + +/** + * 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, + spaceId, + depth, + ancestors = NO_ANCESTORS, +}: { + comment: CommentWithReplies; + /** The entity this comment replies to — the debate, or the extracted claim. */ + targetEntityId: string; + 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 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); + + const spine = useThreadParentSpine(avatarBottomInRowPx(PAGE_DENSITY)); + const body = React.useMemo(() => renderMarkdownDocument(comment.markdownContent), [comment.markdownContent]); + // 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 + // 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; + // 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; + + 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 + itself intercepted to open the profile beside the thread instead of replacing it. */} + + +
+
+ + {comment.author.name ?? 'Anonymous'} + + {/* + 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)} + +
+ +
+ {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. */} + +
+ + + + {/* + 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 + fetching. + */} + {drawnReplies.length > 0 && ( +
+ setRepliesCollapsed(true)} + label={branchLabel} + > + {drawnReplies.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 new file mode 100644 index 0000000000..95ebe9636c --- /dev/null +++ b/apps/web/core/claims/browse/extracted-claim-row.test.tsx @@ -0,0 +1,409 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import '@testing-library/jest-dom/vitest'; +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'; + +// 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, + count, + onActivate, + }: { + entityId: string; + count: number; + onActivate?: () => void; + }) => ( + + ), +})); + +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) => { + 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), + // Visibility lives in the component, not in the row, so the stand-in has to honour it too. + InlineCommentComposer: ({ + composer, + targetEntityId, + }: { + composer: { isComposing: boolean }; + targetEntityId: string; + }) => (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', + 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> = {}) { + // 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( + + + + ); +} + +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('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(); + }); + + // 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 response kind and trailing avatars to the vote control', () => { + renderRow({ responseKind: 'curation' }); + + const votes = screen.getByTestId('vote-buttons'); + expect(votes).toHaveAttribute('data-kind', 'curation'); + 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('shows the server-counted comments rather than seeding the button at zero', () => { + renderRow({ commentCount: 4 }); + + 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. + */ + /** + * 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(); + }); + + /** + * 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(); + unmount(); + + renderRow({ commentCount: null }); + 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(); + + const link = screen.getByRole('link', { name: 'Practical effects age better than CGI.' }); + 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']); + }); + + /** + * jsdom computes no layout, so this asserts the structural condition rather than the pixels. + * + * Twice this row drew a 987px face: once with no sized frame at all, once with the frame wrapped + * in a link, which made the *link* the flex item and left the frame `display: inline` — ignoring + * both dimensions. The frame and the link are one element now, so neither failure has anywhere to + * happen; this pins that down. + */ + it('makes the profile link the sized avatar frame rather than nesting one inside it', () => { + const { container } = renderRow(); + + const frame = container.querySelector('[style*="width"]'); + expect(frame).not.toBeNull(); + expect(frame!.tagName).toBe('A'); + expect(frame!.style.width).toBe('32px'); + expect(frame!.style.height).toBe('32px'); + // And it must not stretch: a stretched frame centres against a row whose height includes + // everything nested under it, which puts the face beside the claim text instead of on the name + // line. + expect(frame!.className).toMatch(/\bself-start\b/); + }); + + 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 new file mode 100644 index 0000000000..374e2f51ed --- /dev/null +++ b/apps/web/core/claims/browse/extracted-claim-row.tsx @@ -0,0 +1,401 @@ +'use client'; + +import * as React from 'react'; + +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'; + +import { PrefetchLink as Link } from '~/design-system/prefetch-link'; + +import { + type CommentDensity, + PAGE_DENSITY, + avatarBottomInRowPx, + threadSpineOffsetPx, +} from '~/partials/comments/comment-density'; +import { EntityCommentsButton } from '~/partials/comments/entity-comments-button'; +import { InlineCommentComposer, useInlineComposer } from '~/partials/comments/inline-comment-composer'; +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'; +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'; + +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. + * + * 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 answers a claim with Agree/Disagree like every other + * claim surface), and `EntityCommentsButton`. Nothing here is a new control. + */ +export function ExtractedClaimRow({ + claim, + debateId, + debateSpaceId, + responseKind, + speaker, + speakerPosition, + commentCount = 0, + depth, + density = PAGE_DENSITY, + 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. + */ + /** + * How this claim is answered. + * + * 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; + /** 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; + /** + * 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. + * + * `null` means the aggregate could not answer. The row then keeps its comments reachable instead + * of reading a failed request as silence. + */ + 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. */ + density?: CommentDensity; + className?: string; +}) { + const composer = useInlineComposer(); + const openSpeakerProfile = useOpenDebaterProfile(speaker?.spaceId, { interactionSurface: 'extracted_claim_speaker' }); + const [commentsCollapsed, setCommentsCollapsed] = React.useState(false); + + // Starts at the avatar's bottom edge, which is what `avatarBottomInRowPx` already means and what + // the comment rows already use — so a claim's spine and a comment's leave from the same place. + const spine = useThreadParentSpine(avatarBottomInRowPx(density)); + + // 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; + + // `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`. + // + // 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 ( + // `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. +
+ {/* The line from this claim's face down to the comments hanging off it. Without it the branch + below draws an elbow reaching back to a spine that was never there — an arm pointing at + nothing, which is what a reader sees as a broken connector. */} + {hasComments && !commentsCollapsed && ( + setCommentsCollapsed(true)} + /> + )} + {/* The frame is the link rather than sitting inside one: an anchor wrapping a sized span makes + the anchor the flex item and leaves the span inline, which is how a 987px face ended up in + a 32px row. `self-start` keeps it on the name's line instead of centred against a row whose + height includes everything nested under it. */} + + +
+
+ + + {speaker?.name?.trim() || 'Unnamed debater'} + + + + {/* 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 ? ( + + + {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 ? ( +
+ {hasComments && ( + setCommentsCollapsed(collapsed => !collapsed)} + /> + )} + + +
+ ) : null} + + {claimSpaceId && ( + + )} + + {/* 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 && ( +
+ setCommentsCollapsed(true)} + label={branchLabel} + /> +
+ )} +
+
+ ); +} + +/** + * 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. + * + * The boundary re-roots the position badge on *this* claim. A comment here sits under this claim's + * sentence, so the badge beside its author has to be about this claim — otherwise it reports the + * page's claim, several screens up, and can say "Agree" over a comment arguing the opposite. It is + * inside the same gate, so it costs a responder read only where there is a comment to badge. + */ +function ClaimComments({ + claimId, + spaceId, + depth, + onCollapse, + label, +}: { + claimId: string; + spaceId: string; + depth: number; + /** + * Collapses the branch, and — separately from that — is what makes the branch draw its spine. + * + * `ThreadBranch` gates the spine on having something to collapse, so a branch passed neither drew + * arms reaching back to a line that did not exist. Every other branch in this feed supplies both. + */ + onCollapse: () => void; + label: { expand: string; collapse: string }; +}) { + 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; + + return ( + // No wrapper of its own. The caller measures where this branch begins in order to stop the spine + // coming down from the avatar exactly there, and a margin *inside* the measured element is 12px + // the spine never covers — the break a reader sees between the control and the first elbow. + // One element, carrying the margin, measured by the caller. + + + {comments.length === 0 ? ( + + void refetch()}>Couldn’t load the replies to this claim. + + ) : null} + + {comments.map((comment, index) => ( + + + + ))} + + + ); +} + +/** + * 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. + * + * The name only. The face is its own link — see `ThreadAvatar`, which is the frame rather than + * something wrapped around one, because an anchor around a sized frame is what made a 987px face + * render in a 32px row. + */ +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 ( + + ); +} 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..6a139148f2 --- /dev/null +++ b/apps/web/core/claims/browse/use-claim-activity-rows.tsx @@ -0,0 +1,191 @@ +'use client'; + +import * as React from 'react'; + +import { useEntityCommentCounts } from '~/core/comments/use-entity-comment-counts'; +import { + DEBATE_CLAIMS_PROPERTY_ID, + DEBATE_OPPOSED_BY_PROPERTY_ID, + DEBATE_SUPPORTED_BY_PROPERTY_ID, + DEBATE_TYPE_ID, +} 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 { useQueryEntities } from '~/core/sync/use-store'; +import type { Entity } from '~/core/types'; + +import type { CommentActivityRow } from '~/partials/comments/types'; + +import { debateCreatedDate, 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; + /** + * The debates read failed, so `rows` being empty says nothing about this claim. + * + * `useQueryEntities` hands this back precisely so a caller drawing an empty state can tell "nothing + * matched" from "the query never came back" — and this feed is the case that documentation warns + * about: dropping it meant a cold-load failure silently omitted every debate and every claim + * extracted from them, while the heading, which is a different query, went on counting them. + */ + error: Error | null; + /** Ask again, so a failed fetch is not "no debates" for as long as the page stays open. */ + retry: () => void; +} { + const { + entities: debates, + isLoading, + error, + refetch, + } = 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, + // Newest first, and said explicitly. The limit above promises the *most recent* few, and an + // unordered bounded query returns whichever page the server likes — so on a claim with more + // debates than the limit the feed would have shown an arbitrary subset while claiming they were + // the latest. `ID_ASC` settles ties, so two debates published in the same second do not swap + // places between reads. + orderBy: [EntitiesOrderBy.CreatedAtDesc, EntitiesOrderBy.IdAsc], + enabled, + }); + + // 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).map(spaceId => ({ + spaceId, + position: true, + })), + ...relationTargets(debate.relations, DEBATE_OPPOSED_BY_PROPERTY_ID).map(spaceId => ({ + spaceId, + position: false, + })), + ]); + } + 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([...sidesByDebateId.values()].flat().map(side => side.spaceId))], + [sidesByDebateId] + ); + const { profilesBySpaceId } = useProfilesBySpaceIds(participantSpaceIds, participantSpaceIds.length > 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; + + 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. 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: ( + + ), + })); + }, [ + claimCountByDebateId, + commentCountByDebateId, + debates, + keyframeByDebateId, + profilesBySpaceId, + sidesByDebateId, + spaceId, + ]); + + const retry = React.useCallback(() => void refetch(), [refetch]); + + return { rows, isLoading, error: (error as Error | null) ?? null, retry }; +} + +/** + * 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/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..e0a998e5c1 --- /dev/null +++ b/apps/web/core/comments/use-entity-comment-counts.ts @@ -0,0 +1,95 @@ +'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 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'; + +/** + * 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. + * + * `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!) { + 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; +}; + +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 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): 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 { data, isError } = 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, + }); + + // 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/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.test.tsx b/apps/web/core/debates/browse/debate-feed-player.test.tsx index 33def59970..80952393a7 100644 --- a/apps/web/core/debates/browse/debate-feed-player.test.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.test.tsx @@ -101,6 +101,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; /** Where the playhead sits, which is all a round cue reads. */ playheadSeconds?: number; }) { @@ -122,7 +126,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, @@ -149,7 +153,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(), }; @@ -837,6 +841,141 @@ describe('a recording whose pipeline dies is rebuilt (GEO-2985)', () => { }); }); +/** + * 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; seekSeconds?: number | null } = {}) { + 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]); + }); + + /** + * 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]); + }); + + /** + * 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); + + player.update(); + + expect(player.seeks).toEqual([]); + }); +}); + describe('the round it is playing', () => { const at = (playheadSeconds: number, extra: { playing?: boolean } = {}) => controllerFixture({ mutedByUser: false, turnSlot: 1, playheadSeconds, ...extra }); diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index ba08258b65..687b134e2f 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -66,9 +66,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. @@ -127,6 +142,41 @@ export function DebateFeedPlayer({ debate, active, preload = false, reducedOverl seekBothRaw(seconds); }; + /** + * 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 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 + * control interaction nobody made here. + */ + const appliedInitialSeekRef = React.useRef(null); + React.useEffect(() => { + // 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; + 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/browse/use-open-debater-profile.test.tsx b/apps/web/core/debates/browse/use-open-debater-profile.test.tsx index 5066205f64..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 @@ -30,10 +30,36 @@ const OTHER_PERSONAL_SPACE_ID = '44444444444444444444444444444444'; function click(result: { current: (event: React.MouseEvent) => void }) { const stopPropagation = vi.fn(); - act(() => result.current({ stopPropagation } as unknown as React.MouseEvent)); + // Both, because the name surfaces are anchors now: the profile opens beside the thread, and the + // href is what makes middle-click and "copy link address" still work. + const preventDefault = vi.fn(); + act(() => result.current({ stopPropagation, preventDefault } as unknown as React.MouseEvent)); 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; @@ -130,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 ab3989fed8..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'; @@ -21,19 +22,27 @@ type PendingProfileOpen = { let latestProfileOpenRequest: symbol | null = null; /** - * Opens a debater's personal space in the side panel. + * Opens a person's personal space in the side panel. * * A personal space's own id resolves to its "system entity" — an ugly technical record. The space's * topic entity is the real profile, so that is what opens. Older personal spaces without an explicit * topic use their page entity, which is also what the personal-space route renders as the profile. * - * Two surfaces name a debater over the video: the row in the corner of their own tile, and the - * header of every claim card. Both are the same link to the same person, so both ask here — a rule - * decided twice is a rule that will eventually disagree with itself. + * Several surfaces name a person: the row in the corner of their own debate tile, the header of + * every claim card, the speaker on an extracted claim, the author of a comment in the claim's + * activity thread. All are the same link to the same person, so all ask here — a rule decided twice + * is a rule that will eventually disagree with itself. The name is historical; nothing about this + * is specific to a debater. + * + * Takes a personal-space id, or the participant record that carries one. Never the person's entity + * id: the resolution below is exactly what turns the space into the profile. */ -export function useOpenDebaterProfile(participant: Pick | null | undefined) { +export function useOpenDebaterProfile( + participant: Pick | string | null | undefined, + { interactionSurface = 'debate_media' }: { interactionSurface?: string } = {} +) { const { openSidePanel, sidePanelTarget } = useEntitySidePanel(); - const profileSpaceId = participant?.profile_space_id; + const profileSpaceId = typeof participant === 'string' ? participant : participant?.profile_space_id; const { space } = useSpace(profileSpaceId); // Prefer the declared topic even when its nested entity failed to decode and `space.entity` fell // back to the page. Never fall back to the space id: that id is the system entity, not the person. @@ -43,11 +52,11 @@ export function useOpenDebaterProfile(participant: Pick { if (!profileSpaceId || !profileEntityId) return; personProfileOpened(profileSpaceId, profileEntityId, { - interaction_surface: 'debate_media', + interaction_surface: interactionSurface, navigation_mode: 'entity_side_panel', }); openSidePanel(profileEntityId, profileSpaceId, false, { forceRequestedSpace: true }); - }, [openSidePanel, profileEntityId, profileSpaceId]); + }, [interactionSurface, openSidePanel, profileEntityId, profileSpaceId]); React.useEffect(() => { const pendingProfileOpen = pendingProfileOpenRef.current; @@ -69,7 +78,21 @@ export function useOpenDebaterProfile(participant: Pick { - // The video behind is one large play/pause button. + // 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(); event.stopPropagation(); if (!profileSpaceId) return; diff --git a/apps/web/core/debates/claim-timing.test.ts b/apps/web/core/debates/claim-timing.test.ts index d19bceb733..7a5121403b 100644 --- a/apps/web/core/debates/claim-timing.test.ts +++ b/apps/web/core/debates/claim-timing.test.ts @@ -460,6 +460,20 @@ describe('formatTimecode', () => { expect(formatTimecode(134_600)).toBe('2:15'); expect(formatTimecode(270_000)).toBe('4:30'); }); + + // The hour cases are why there is one of these rather than two: the first version printed a claim + // an hour into a debate as `62:04`. + it('grows an hours field only once there are hours', () => { + expect(formatTimecode(724_000)).toBe('12:04'); + expect(formatTimecode(4_000)).toBe('0:04'); + expect(formatTimecode(3_724_000)).toBe('1:02:04'); + expect(formatTimecode(3_600_000)).toBe('1:00:00'); + }); + + it('degrades to zero rather than printing NaN for a corrupt offset', () => { + expect(formatTimecode(Number.NaN)).toBe('0:00'); + expect(formatTimecode(-1)).toBe('0:00'); + }); }); /** diff --git a/apps/web/core/debates/claim-timing.ts b/apps/web/core/debates/claim-timing.ts index 0dff09260e..a3243992c1 100644 --- a/apps/web/core/debates/claim-timing.ts +++ b/apps/web/core/debates/claim-timing.ts @@ -501,10 +501,26 @@ export function sortClaimsBySpokenOrder( .map(entry => entry.claim); } -/** `2:05`, for a timecode chip. */ +/** + * `2:05`, for a timecode chip. + * + * Hours only when there are hours: `12:04` rather than `00:12:04`, and `1:02:04` once a debate runs + * past the hour — which the first version of this did not do, printing a claim an hour in as + * `62:04`. Minutes keep their leading zero inside an hour-long stamp so the columns line up. + * + * Rounds to the nearest second rather than flooring, because this is a label rather than a seek + * target; `debateSeekSeconds` is what decides where the video actually lands. + */ export function formatTimecode(ms: number): string { - const total = Math.max(0, Math.round(ms / 1000)); - const minutes = Math.floor(total / 60); + if (!Number.isFinite(ms) || ms < 0) return '0:00'; + + const total = Math.round(ms / 1000); const seconds = total % 60; - return `${minutes}:${seconds.toString().padStart(2, '0')}`; + 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/debate-timecode.test.ts b/apps/web/core/debates/debate-timecode.test.ts new file mode 100644 index 0000000000..82d9809d8d --- /dev/null +++ b/apps/web/core/debates/debate-timecode.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { + DEBATE_TIME_PARAM, + TIMECODE_PREROLL_SECONDS, + debateSeekSeconds, + 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`); + }); +}); diff --git a/apps/web/core/debates/debate-timecode.ts b/apps/web/core/debates/debate-timecode.ts new file mode 100644 index 0000000000..a53bf54db8 --- /dev/null +++ b/apps/web/core/debates/debate-timecode.ts @@ -0,0 +1,80 @@ +/** + * 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}`; +} 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/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..613e5c951f --- /dev/null +++ b/apps/web/core/debates/use-debate-claim-counts.ts @@ -0,0 +1,89 @@ +'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 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'; + +/** + * 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; +}; + +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 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, isError } = 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, + }); + + // Memoized for the same reason the comment counts are: this lands in a `useMemo` dependency list. + return React.useMemo(() => batchedCounts(data, isError), [data, isError]); +} diff --git a/apps/web/core/debates/use-debate-transcript-claims.ts b/apps/web/core/debates/use-debate-transcript-claims.ts index cf2c169433..492d2c9d54 100644 --- a/apps/web/core/debates/use-debate-transcript-claims.ts +++ b/apps/web/core/debates/use-debate-transcript-claims.ts @@ -16,6 +16,8 @@ export type DebateTranscriptClaimsResult = { claims: DebateTranscriptClaims; isLoading: boolean; error: Error | null; + /** Ask again. A failed transcript read is worth offering to retry rather than reporting as empty. */ + retry: () => 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/core/explore/explore-card-item.ts b/apps/web/core/explore/explore-card-item.ts index 8536c260c2..dbbf32600b 100644 --- a/apps/web/core/explore/explore-card-item.ts +++ b/apps/web/core/explore/explore-card-item.ts @@ -1,3 +1,4 @@ +import { countActivityForNode } from '~/core/claims/browse/claim-activity-fields'; import { ContentIds, SystemIds } from '@geoprotocol/geo-sdk/lite'; import { getRecordingUrls } from '~/core/community-calls/recordings'; @@ -53,6 +54,21 @@ export type ExploreFeedItem = { /** The claim this Debate argued. `null` on every non-debate, and on a debate missing the relation. */ debateClaim: ExploreDebateClaim | null; commentCount: number; + /** + * Debates, extracted claims and every comment under them — what the claim page's Activity heading + * counts. Optional because only a claim card reads it, and only the explore selections fetch it; + * a row built by hand or by another feed simply has none. + */ + activityCount?: number; + /** + * How many of {@link activityCount} are this claim's own comments. + * + * The card's live count needs it to stay exact: the comment list it can watch measures precisely + * this part of the total, so swapping the fetched-at number for the live one keeps the rest of the + * aggregate intact. Without it the pill had to guess from pending rows and fell back to the stale + * total the moment a post was indexed. + */ + activityCommentCount?: number; isMemberOrEditor: boolean; hasPendingMembershipRequest: boolean; }; @@ -87,8 +103,13 @@ export function toExploreFeedItem(row: ExploreFeedRow, label: { name: string; im } satisfies ExploreFeedItem; } -/** A decoded entity plus the two fields the card needs that aren't part of `Entity`. */ -export type ExploreCardEntity = Entity & { commentCount: number; createdAt?: string }; +/** A decoded entity plus the fields the card needs that aren't part of `Entity`. */ +export type ExploreCardEntity = Entity & { + commentCount: number; + activityCount: number; + activityCommentCount: number; + createdAt?: string; +}; /** The comment relation type — `backlinks` through it is how a card gets its comment count. */ export const COMMENT_RELATION_TYPE_ID = '310d4a240e5b451cb2151bfce40d0fe6'; @@ -105,7 +126,17 @@ export function decodeExploreCardEntity(node: unknown): ExploreCardEntity | null const raw = node as Record & { backlinks?: { totalCount?: number } | null; createdAt?: string }; const decoded = EntityDecoder.decode(raw); if (!decoded) return null; - return { ...decoded, commentCount: raw.backlinks?.totalCount ?? 0, createdAt: raw.createdAt }; + const activity = countActivityForNode(raw); + + return { + ...decoded, + commentCount: raw.backlinks?.totalCount ?? 0, + // Everything in this claim's activity tree, counted the same way the claim page counts it. + // Zero for anything that is not a claim, which is what those aggregates come back as. + activityCount: activity.total, + activityCommentCount: activity.comments, + createdAt: raw.createdAt, + }; } const DEBATE_CLAIMS_RELATION = normId(DEBATE_CLAIMS_PROPERTY_ID); @@ -278,6 +309,8 @@ export function buildExploreFeedRows( debateVideoUrls: getRelationVideoUrls(relationsInDisplaySpace, DEBATE_VIDEOS_PROPERTY_ID), debateClaim: debateClaimFromEntity(types, relationsInDisplaySpace), commentCount: e.commentCount, + activityCount: e.activityCount, + activityCommentCount: e.activityCommentCount, isMemberOrEditor: memberOrEditorSpaceIds.has(normId(spaceId)), }); } diff --git a/apps/web/core/explore/explore-card-selection.test.ts b/apps/web/core/explore/explore-card-selection.test.ts new file mode 100644 index 0000000000..8934464698 --- /dev/null +++ b/apps/web/core/explore/explore-card-selection.test.ts @@ -0,0 +1,45 @@ +import { parse } from 'graphql'; +import { describe, expect, it } from 'vitest'; + +import { exploreCardNodeFields, exploreCardPropertyFragment } from './explore-card-selection'; + +/** + * The card selection is assembled as a string, so nothing type-checks it — a stray brace or an + * interpolation that resolves to nothing only shows up as a 500 from whichever route embeds it. + * Parsing it here is the cheapest place to find that out. + */ +describe('explore card selection', () => { + it('parses as a selection set', () => { + expect(() => parse(`query { entities { ${exploreCardNodeFields('Frag')} } }`)).not.toThrow(); + }); + + it('parses with lists unscoped, as the profile and topic feeds ask for it', () => { + expect(() => + parse(`query { entities { ${exploreCardNodeFields('Frag', { scopeListsToSpaces: false })} } }`) + ).not.toThrow(); + }); + + it('parses its property fragment', () => { + expect(() => parse(exploreCardPropertyFragment('Frag'))).not.toThrow(); + }); + + it('asks for the activity totals a claim card shows', () => { + const fields = exploreCardNodeFields('Frag'); + + expect(fields).toContain('activityComments'); + expect(fields).toContain('activityDebates'); + }); +}); + +// The documents that embed it are `parse`d at module load, so importing them is the assertion: +// a malformed selection throws here rather than as a 500 from the route that uses it. +describe('documents built from it', () => { + it('parse at import time', async () => { + await expect(import('./explore-best-document')).resolves.toBeDefined(); + await expect(import('./explore-entities-document')).resolves.toBeDefined(); + await expect(import('./explore-best-by-type-document')).resolves.toBeDefined(); + await expect(import('./explore-entities-by-property-document')).resolves.toBeDefined(); + await expect(import('./explore-complete-index-document')).resolves.toBeDefined(); + await expect(import('./fetch-explore-feed')).resolves.toBeDefined(); + }); +}); diff --git a/apps/web/core/explore/explore-card-selection.ts b/apps/web/core/explore/explore-card-selection.ts index dff5a718e5..b5ffce327a 100644 --- a/apps/web/core/explore/explore-card-selection.ts +++ b/apps/web/core/explore/explore-card-selection.ts @@ -3,6 +3,8 @@ import { ContentIds, SystemIds } from '@geoprotocol/geo-sdk/lite'; import { EVENT_SCHEMA } from '~/core/community-calls/constants'; import { DEBATE_CLAIMS_PROPERTY_ID, DEBATE_VIDEOS_PROPERTY_ID } from '~/core/debates/ontology'; +import { CLAIM_ACTIVITY_COUNT_FIELDS } from '~/core/claims/browse/claim-activity-fields'; + import { COMMENT_RELATION_TYPE_ID } from './explore-card-item'; import { EXPLORE_AVATAR_PROPERTY_ID, @@ -99,6 +101,11 @@ export function exploreCardNodeFields(fragmentName: string, options: ExploreCard totalCount } + ${'' /* Activity totals for a claim card. Measured against testnet on 2026-09-24: +0.02s and + +1 KB over a 20-card page, because for anything that is not a claim these relation + lookups come back empty immediately. Cheap enough not to need a claim-only selection. */} + ${CLAIM_ACTIVITY_COUNT_FIELDS} + types { id name 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/core/hooks/use-comment-count.test.tsx b/apps/web/core/hooks/use-comment-count.test.tsx index 9469f366fc..900db00dad 100644 --- a/apps/web/core/hooks/use-comment-count.test.tsx +++ b/apps/web/core/hooks/use-comment-count.test.tsx @@ -66,6 +66,91 @@ 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('replaces only the comments part of a broader seed', 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, { commentsInSeed: 1 }), + 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); + }); + + /** + * The failure a pending-row delta had: once a published row is indexed it loses `isPendingPublish` + * and the pill fell back to the seed — 33 → 34 → 33 with the comment still on screen. Counting the + * list's own share instead holds through indexing, because the row never leaves the list. + */ + it('keeps a published comment counted once it is indexed and no longer flagged', async () => { + // The control again, and for the same reason: the expected value after indexing is the value it + // already held, so `waitFor` alone would pass before the re-render it is meant to observe. The + // plain reading moving to 2 is proof the new list landed. + const { result } = renderHook( + () => ({ + broader: useCommentCount(ENTITY_ID, 33, { commentsInSeed: 1 }), + plain: useCommentCount(ENTITY_ID, 33), + }), + { wrapper } + ); + + vi.setSystemTime(2_000); + act(() => { + writeFetchedList([comment('c1'), comment('c2', { isPendingPublish: true } as Partial)]); + }); + await waitFor(() => expect(result.current.broader).toBe(34)); + + // Indexed: the server row replaces the optimistic one and carries no pending flag. + vi.setSystemTime(3_000); + act(() => { + writeFetchedList([comment('c1'), comment('c2')]); + }); + + await waitFor(() => expect(result.current.plain).toBe(2)); + expect(result.current.broader).toBe(34); + }); + + it('gives back the count when a failed publish takes its row out again', async () => { + const { result } = renderHook( + () => ({ + broader: useCommentCount(ENTITY_ID, 33, { commentsInSeed: 1 }), + plain: useCommentCount(ENTITY_ID, 33), + }), + { wrapper } + ); + + vi.setSystemTime(2_000); + act(() => { + writeFetchedList([comment('c1'), comment('c2', { isPendingPublish: true } as Partial)]); + }); + await waitFor(() => expect(result.current.broader).toBe(34)); + + vi.setSystemTime(3_000); + act(() => { + writeFetchedList([comment('c1')]); + }); + + await waitFor(() => expect(result.current.plain).toBe(1)); + expect(result.current.broader).toBe(33); + }); + 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..8e1351e916 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,30 @@ export function resetCommentCountSeeds() { seeds.clear(); } -export function useCommentCount(entityId: string, serverCount: number): number { +export function useCommentCount( + entityId: string, + serverCount: number, + { + commentsInSeed, + }: { + /** + * How many of `serverCount` are this entity's comments, when the seed counts more than them. + * + * The claim card's pill is an *activity* total — the claim's debates, the claims extracted from + * them, and every comment in that tree. The comment list is a strict subset of it, so the rule + * below ("once the list has answered it *is* the count") turned a pill reading 33 into one + * reading 1 as soon as anything fetched that list; the claim page does on every visit, so + * opening a claim and going back to Explore was enough. + * + * Given this, the list still replaces — but only its own share. `serverCount - commentsInSeed` + * is the part of the total the list cannot see, and the list's length is the part it can, so the + * answer stays exact through publishing, indexing and rollback alike. Counting pending rows + * instead would have been right only while a row was in flight: once indexed, the row loses its + * flag and the pill fell back to the seed, going 33 → 34 → 33 with the comment still there. + */ + commentsInSeed?: number; + } = {} +): 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 +104,11 @@ 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; + // The list replaces the whole count when it measures the whole count, and only its own share + // when the seed is broader — see `commentsInSeed`. + if (hasFetchedList === true) { + return commentsInSeed == null ? data.length : Math.max(0, serverCount - commentsInSeed) + data.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/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/core/hooks/use-create-comment.test.tsx b/apps/web/core/hooks/use-create-comment.test.tsx new file mode 100644 index 0000000000..fa9d6282e1 --- /dev/null +++ b/apps/web/core/hooks/use-create-comment.test.tsx @@ -0,0 +1,87 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook } from '@testing-library/react'; + +import type * as React from 'react'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { CommentEntity } from '~/partials/comments/types'; + +// Signed in, with no personal space yet: `createComment` inserts its optimistic row and then returns +// `{ published: false }` before any transaction — the retained path, and the only one this file is +// about. Nothing here reaches the chain. +vi.mock('./cached-write-identity', () => ({ + 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 37c07d9b8a..284c0ffc27 100644 --- a/apps/web/core/hooks/use-publish-comment.test.ts +++ b/apps/web/core/hooks/use-publish-comment.test.ts @@ -50,11 +50,13 @@ describe('usePublishComment', () => { 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', { @@ -91,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( @@ -144,3 +148,166 @@ 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); + } + + /** + * 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); + + 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(); + + 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 () => { + createCommentThat({ 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 }; + createCommentThat(null); + + await expect(queued.run()).rejects.toThrow('Comment could not be published'); + 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. + */ + /** + * 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 }) => { + 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(() => + result.current.publishComment({ + text: 'A comment', + onOptimistic: () => void counted.push(1), + onFailed: () => void counted.push(-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; + + 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 row is back, so the comment counts — once. + expect(rowPresent).toBe(true); + expect(total()).toBe(1); + }); + + it('says nothing when the retry succeeds', async () => { + createCommentThat({ 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..ae44713189 100644 --- a/apps/web/core/hooks/use-publish-comment.ts +++ b/apps/web/core/hooks/use-publish-comment.ts @@ -11,6 +11,20 @@ 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. + * + * Paired with {@link onOptimistic} as a transition rather than an event: at most one of the two is + * outstanding at a time, so a retried publish that keeps failing does not report the same loss + * repeatedly, and one that eventually succeeds reports the row back. + */ + onFailed?: () => void; }; type CommentAnalyticsContext = { @@ -35,7 +49,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; @@ -53,29 +67,67 @@ 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) => { + if (countedByCaller) return; + countedByCaller = true; + onOptimistic?.(commentId); + }; + const uncountRow = () => { + if (!countedByCaller) return; + countedByCaller = false; + onFailed?.(); + }; + const result = await createComment({ text, targetSpaceId, ancestorComments, - onOptimistic, + onOptimistic: countRow, }); - if (!result) return result; + if (!result) { + uncountRow(); + 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'); - }), + // 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)) 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. + uncountRow(); + throw new Error('Comment could not be published'); + }, }); return result; 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/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/activity-posts.tsx b/apps/web/partials/comments/activity-posts.tsx new file mode 100644 index 0000000000..c895868c1c --- /dev/null +++ b/apps/web/partials/comments/activity-posts.tsx @@ -0,0 +1,53 @@ +'use client'; + +import * as React from 'react'; + +/** + * 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 + * 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 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. + * + * 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); + +export function ActivityPostsProvider({ + onAdjust, + children, +}: { + onAdjust: (delta: number) => void; + children: React.ReactNode; +}) { + return {children}; +} + +/** + * Reports a comment appearing or disappearing here, if anything is counting. + * + * 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. 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 + * it. + */ +export function useAdjustActivityPosts(): (delta: number) => void { + const adjust = React.useContext(ActivityPostsContext); + return React.useCallback((delta: number) => adjust?.(delta), [adjust]); +} 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..75020646c2 --- /dev/null +++ b/apps/web/partials/comments/comment-activity-rows.test.tsx @@ -0,0 +1,440 @@ +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. + * + * 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); + }), +})); + +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: 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: mocks.smartAccount }) })); +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(), +})); +// #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 })); +// 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 { + 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: [], + }; +} + +// 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( + + ) + ); +} + +/** 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(); + }); + + 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', + 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()); + + // 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']); + }); + + // 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', + entityId: 'd-1', + spaceId: 'space-1', + createdAt: '2026-09-21T10:00:00Z', + content: debate, + }, + ]); + + 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('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(); + + await publishAComment(); + + // 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, + }, + ]); + + 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(withClient()); + + expect(screen.getByText('Comments (1)')).toBeInTheDocument(); + 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/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/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 new file mode 100644 index 0000000000..5384ecd6a8 --- /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} 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`; + return date.toLocaleDateString(); +} diff --git a/apps/web/partials/comments/comments-section.tsx b/apps/web/partials/comments/comments-section.tsx index 606eea8033..b842c04804 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'; @@ -26,45 +27,48 @@ import { usePublishComment } from '~/core/hooks/use-publish-comment'; import { useSmartAccount } from '~/core/hooks/use-smart-account'; import { useSpaceRoles } from '~/core/hooks/use-space-editor-ids'; import { uuidToHex } from '~/core/id/normalize'; +import { useEntityResponseScores } from '~/core/responses/use-entity-response-scores'; import { renderMarkdownDocument } from '~/core/state/editor/markdown-render'; import { pendingCommentComposerAtom } from '~/core/state/pending-comment-intents'; 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'; import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; +import { ActivityPostsProvider } from './activity-posts'; 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 type { CommentFilter, CommentSortOrder, CommentWithReplies } from './types'; +import { getRelativeTime } from './comment-time'; +import { ThreadAvatar } from './thread-avatar'; +import { ThreadCollapseToggle, ThreadListSpine, ThreadParentSpine, branchPointerBlurProps } from './thread-branch'; +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[] = []; +/** 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); } -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 }; @@ -202,24 +206,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'; interface CommentSectionProps { @@ -235,6 +221,43 @@ 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; + /** + * The number beside the heading, where the host knows something the rendered list does not. + * + * The claim page's Activity count includes what is nested under each debate — its extracted + * claims and every comment in that tree — which this component never sees. Omitted elsewhere, and + * 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. + */ + defaultSortOrder?: CommentSortOrder; } export function CommentSection({ @@ -242,6 +265,11 @@ export function CommentSection({ spaceId, targetEntityType = 'entity', variant = 'page', + activityRows = NO_ACTIVITY_ROWS, + title = 'Comments', + totalOverride, + onActivityPublish, + defaultSortOrder = 'newest', }: CommentSectionProps) { const { comments, totalCount, isLoading } = useComments({ entityId, spaceId }); const { publishComment, editComment } = usePublishComment(entityId, spaceId, { @@ -305,7 +333,22 @@ 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(() => { @@ -342,6 +385,13 @@ export function CommentSection({ setSessionNewIds((prev: string[]) => (prev.includes(id) ? prev : [id, ...prev])); }, []); + // 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 // is updated via the onOptimistic callback so the row pins to the top right away. @@ -352,10 +402,17 @@ export function CommentSection({ void publishComment({ text, ancestorComments, - onOptimistic: markSessionNew, + onOptimistic: commentId => { + markSessionNew(commentId); + 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), }); }, - [markSessionNew, publishComment, smartAccount] + [adjustActivityPosts, markSessionNew, publishComment, smartAccount] ); const handleEditComment = React.useCallback( @@ -395,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') { @@ -405,70 +466,80 @@ export function CommentSection({ return ( - -
- {!isPanel && ( - <> -
Comments ({totalCount})
- - - )} - - {totalCount > 0 && ( - <> - - - - )} - {isLoading ? ( -
- - Loading comments... - -
- ) : ( - filteredComments.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}) +
- - - setPendingReplyToId(null)} - /> - - - ) - )} -
-
+ )} + + {totalCount + activityRows.length > 0 && ( + <> + + + + )} + {isLoading ? ( +
+ + Loading comments... + +
+ ) : ( + (filteredComments.length > 0 || activityRows.length > 0) && ( + <> + + + + setPendingReplyToId(null)} + /> + + + + ) + )} +
+
+
); } @@ -507,6 +578,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, @@ -521,11 +601,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'}} @@ -538,7 +620,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, @@ -577,12 +659,7 @@ function TopLevelCommentInput({ onClick={openComposer} className="flex w-full items-center gap-3 border-b border-grey-02 pb-3 text-left" > - - - + Join the conversation... @@ -595,7 +672,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... ); } @@ -724,6 +804,10 @@ export function CommentInput({ function CommentList({ comments, + activityRows = NO_ACTIVITY_ROWS, + activityOrder = 'newest', + activityScoreFor, + activityPinnedIds, entityId, spaceId, onReply, @@ -742,6 +826,19 @@ function CommentList({ parentCommentId, }: { comments: CommentWithReplies[]; + /** 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; + /** + * 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; @@ -806,30 +903,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, activityScoreFor, activityPinnedIds); + return (
- {comments.map((comment, index) => ( - - ))} + {merged.map((entry, index) => + entry.kind === 'extra' ? ( +
0 && 'border-t border-divider')}> + {entry.row.content} +
+ ) : ( + + ) + )}
); } @@ -855,32 +962,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; @@ -1093,7 +1186,6 @@ function CommentItem({ const nestedSpineLeftPx = -threadSpineOffsetPx(density); /** Horizontal center of the branch line for the toggle (`commentRef` coordinates): */ const threadLineCenterXFromRootPx = depth === 0 || hasReplies ? density.avatarCenterPx : nestedSpineLeftPx; - const threadLineStrokeCenterNudgePx = 0.5; /** X of thread line relative to body inner left (vote row). */ const threadToggleLeftInBodyPx = threadLineCenterXFromRootPx - density.bodyInsetPx; const commentRef = React.useRef(null); @@ -1136,14 +1228,15 @@ function CommentItem({ className="flex shrink-0 items-center justify-center" style={{ width: density.avatarPx, height: density.avatarPx }} > - - - + avatarUrl={comment.author.avatarUrl} + value={comment.author.address} + sizePx={density.avatarPx} + />
{/* Single line, no wrapping: a wrapped header doubles the row height, and because the avatar is centred against it the body ends up stranded far @@ -1234,24 +1327,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} @@ -1414,20 +1480,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/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 7af64bec68..41ce99f171 100644 --- a/apps/web/partials/comments/entity-comments-button.tsx +++ b/apps/web/partials/comments/entity-comments-button.tsx @@ -15,6 +15,11 @@ import { ExploreCommentsIcon } from '~/partials/explore/explore-comments-icon'; * they're reading. Only an entity's own full page renders comments inline * instead. Rendered as a button rather than a link so nested row links (the * card title, the row itself) don't swallow the click. + * + * `onActivate` is for the surface that is *already* showing the thread. The claim page's activity + * feed draws a debate's comments under the debate, so sending the reader to the panel there swaps a + * thread they can see for a flat list of the same rows, minus the context. Those rows pass a + * handler and open a composer in place instead. Everywhere else is unchanged. */ export function EntityCommentsButton({ entityId, @@ -22,6 +27,9 @@ export function EntityCommentsButton({ targetEntityType = 'entity', count, className, + onActivate, + isActive, + commentsInCount, }: { entityId: string; spaceId: string; @@ -29,26 +37,48 @@ export function EntityCommentsButton({ /** A server-rendered count; the live one takes over as soon as the list has been read. */ count: number; className?: string; + /** Replaces opening the global panel. The caller is then responsible for showing something. */ + onActivate?: () => void; + /** What the button reports as expanded when the caller owns the disclosure. */ + isActive?: boolean; + /** + * How many of `count` are this entity's comments, when `count` measures more than them. + * + * 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`. + */ + commentsInCount?: number; }) { const { commentsTarget, openComments } = useEntityCommentsPanel(); - const isOpen = commentsTarget?.entityId === entityId; + 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, { commentsInSeed: commentsInCount }); 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 new file mode 100644 index 0000000000..3ebf0fcbd9 --- /dev/null +++ b/apps/web/partials/comments/inline-comment-composer.tsx @@ -0,0 +1,162 @@ +'use client'; + +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'; + +/** + * A composer that opens where the reader is, against an entity the surrounding thread does not own. + * + * The claim page's activity feed holds rows for three different entities — the claim, the debates + * on it, and the claims extracted from those — and until now every one of them answered "comment" + * by opening the global panel. That is the right answer on an explore card, where there is nothing + * around the row to lose. It is the wrong one here: the panel replaces the thread the reader is + * reading with a flat list of one entity's comments, so the debate, the claim it argued and the + * position badges all go away at the moment the reader wants to respond to them. Reddit never moves + * you to reply, and the thread is legible for it. + * + * The write itself is unchanged — `usePublishComment` against the target entity, the same optimistic + * row, the same retry when a personal space is still being created — so a comment made here and one + * made in the panel are the same comment. + */ +export function InlineCommentComposer({ + composer, + targetEntityId, + targetSpaceId, + targetEntityType = 'entity', + ancestors, + placeholder, +}: { + /** + * The row's disclosure, from {@link useInlineComposer}. + * + * Passed whole rather than as an `isComposing` prop plus two callbacks, because every caller wired + * the same three things the same way around the same conditional — and the spacing above the box + * belongs to the box, not to three copies of a wrapper. + */ + composer: InlineComposerState; + /** The entity being commented on: the debate, or the extracted claim, not the page's claim. */ + targetEntityId: string; + targetSpaceId: string; + targetEntityType?: string; + /** + * The comment chain above this one, nearest first, when replying rather than starting a thread. + * + * A reply carries `Reply to` to every ancestor, which is what lets one aggregate count a whole + * tree — so an incomplete chain here does not merely mislabel the reply, it drops it out of the + * counts the feed draws from. + */ + ancestors?: Array<{ id: string; spaceId: string }>; + placeholder: string; +}) { + const { publishComment } = usePublishComment(targetEntityId, targetSpaceId, { + targetEntityType, + interactionSurface: 'activity_feed', + }); + const { smartAccount } = useSmartAccount(); + const promptSignIn = usePrivySignIn(); + const isSignedIn = !!smartAccount; + const { isComposing, close } = composer; + const adjustActivityPosts = useAdjustActivityPosts(); + + /** + * 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 ( +
+ { + // Fire and forget, like every other composer: the box closes now and the optimistic row + // carries the "Publishing…" state. + void publishComment({ + text, + ancestorComments: ancestors, + // 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. The branch + // needs no telling here either — the row leaving the list is the signal. + onFailed: () => adjustActivityPosts(-1), + }); + close(); + }} + /> +
+ ); +} + +/** + * Whether a row should be drawing a composer, and the toggle that says so. + * + * Each row owns this rather than a shared store, because two composers open at once in different + * branches is a reasonable thing to want — a reader part-way through a reply should not lose it by + * opening another. It also means nothing has to be torn down when a branch collapses. + */ +export type InlineComposerState = ReturnType; + +export function useInlineComposer() { + const [isComposing, setIsComposing] = React.useState(false); + + // 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, + toggle: () => setIsComposing(open => !open), + open: () => setIsComposing(true), + close: () => setIsComposing(false), + }), + [isComposing] + ); +} diff --git a/apps/web/partials/comments/thread-avatar.tsx b/apps/web/partials/comments/thread-avatar.tsx new file mode 100644 index 0000000000..83229f4e95 --- /dev/null +++ b/apps/web/partials/comments/thread-avatar.tsx @@ -0,0 +1,69 @@ +'use client'; + +import * as React from 'react'; + +import cx from 'classnames'; + +import { Avatar } from '~/design-system/avatar'; + +/** + * A face in a thread, at the size its row asks for. + * + * `Avatar` fills its container whenever it has a real `avatarUrl` to draw — `size` only sizes the + * generated fallback — so every call site has to supply a frame with a definite width and height. + * The repo does that inline in dozens of places, and this PR got it wrong twice in the same row: + * once by omitting the frame, and once by wrapping the frame in a link, which left the frame + * `display: inline` and had a 987px face render in a 32px row. + * + * Both failures came from the frame and the element around it being separate things. Here they are + * one thing: pass `href` and the frame *is* the link, so there is nothing to nest and nothing to + * blockify. + */ +export function ThreadAvatar({ + avatarUrl, + value, + sizePx, + href, + label, + onClick, + className, +}: { + avatarUrl?: string | null; + /** Seed for the generated fallback — an address or a space id, whatever the row has. */ + value?: string | null; + sizePx: number; + /** Renders an anchor rather than a span. Omit where there is no person to open. */ + href?: string; + /** + * Who this face belongs to, for the link's accessible name. + * + * A face is the one kind of 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. So a linked + * avatar announced as nothing at all, and there is no way to tell from the keyboard where it goes. + * Required whenever `href` is — the name is always to hand at these call sites, because the row is + * already printing it. + */ + label?: string; + onClick?: (event: React.MouseEvent) => void; + className?: string; +}) { + // `shrink-0` because these sit in flex rows that would otherwise squeeze the frame narrower than + // its height and turn the circle into an ellipse. + const frameClassName = cx('relative shrink-0 overflow-hidden rounded-full', className); + const frameStyle = { width: sizePx, height: sizePx }; + const face = ; + + if (!href) { + return ( + + {face} + + ); + } + + return ( + + {face} + + ); +} 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..713c1f31d9 --- /dev/null +++ b/apps/web/partials/comments/thread-branch-list.tsx @@ -0,0 +1,127 @@ +'use client'; + +import * as React from 'react'; + +import { type CommentDensity, threadArmCenterPx } 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({ + rowDensity, + reachPx, + onCollapse, + label, + children, +}: { + /** + * 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. + */ + rowDensity: CommentDensity; + /** How far left of this branch's edge the parent's spine sits — the parent's geometry, not ours. */ + reachPx: number; + /** + * Collapses the branch — and, less obviously, is what makes it draw a spine at all. + * + * Required, and that is the point. These used to be optional, so a branch could be created with + * neither: the rows still drew their arms and their elbow, reaching back to a line that was never + * rendered. On screen that is an arm pointing at nothing, which reads as a broken connector rather + * than as a missing control, and it shipped on two of the three branches in the claim feed. + * Making them required means a spineless branch cannot be written. + */ + onCollapse: () => void; + label: { expand: string; collapse: string }; + children: React.ReactNode; +}) { + 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. + 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, armCenterPx: arm, registerLastRow: lastRowRef }), + [arm, reachPx] + ); + + return ( + +
+ + {children} +
+
+ ); +} + +/** One row in a branch, with the connector tying it back to the spine. */ +export function ThreadBranchRow({ isLast, children }: { isLast: boolean; children: React.ReactNode }) { + const branch = React.useContext(ThreadBranchContext); + // Outside a branch there is nothing to connect to, so draw nothing rather than a line to nowhere. + if (!branch) return <>{children}; + const { reachPx, armCenterPx } = branch; + + return ( +
+
+ {isLast ? ( + + ) : ( + + )} +
+ {children} +
+ ); +} diff --git a/apps/web/partials/comments/thread-branch.tsx b/apps/web/partials/comments/thread-branch.tsx new file mode 100644 index 0000000000..5f3fb22553 --- /dev/null +++ b/apps/web/partials/comments/thread-branch.tsx @@ -0,0 +1,326 @@ +'use client'; + +import * as React from 'react'; + +import cx from 'classnames'; + +import { Minus } from '~/design-system/icons/minus'; +import { Plus } from '~/design-system/icons/plus'; + +import { + THREAD_LEVEL_BRANCH_SEGMENT, + THREAD_SEGMENT_DIM, + THREAD_SEGMENT_DIM_STROKE, + THREAD_SEGMENT_HI, + THREAD_SEGMENT_HI_STROKE, +} from './comment-density'; + +/** + * The connectors that hang a nested list off the row above it. + * + * Comment replies have always drawn these; the claim page's activity feed needs the same shape for + * 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. + * + * 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. + * + * Both handlers check `relatedTarget`: a pointer moving between the spine and the arm it feeds, or + * focus stepping from one control in the branch to the next, is still inside the branch. Clearing + * on those made a branch flicker as the reader tracked along it. + */ +export function branchPointerBlurProps( + clearFocus: () => 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 }; +} + +/** What every connector needs to know about the branch it belongs to. */ +type BranchFocusHandlers = { + lit: boolean; + onFocusBranch?: () => void; + onPressBranch?: () => void; + onClearFocus?: () => void; +}; + +/** + * The vertical line descending from a row into the branch beneath it, which collapses that branch. + * + * 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. + */ +/** + * The measured length of the spine descending from a row's avatar to the branch hanging off it. + * + * Three rows in the claim feed each grew their own copy of this — the same two refs, the same + * state, the same `getBoundingClientRect` subtraction and the same `ResizeObserver` — differing + * only in where the spine starts. Measured rather than computed because these rows are variable + * height: a claim sentence wraps to one line or three, a comment body to any number. + * + * **Put the ref on the element that carries the branch's top margin.** The spine stops where + * `branchRef` begins, so a margin *inside* that element is length the spine never covers — which + * shipped as a 12px break between a claim's collapse control and the first comment under it. + * + * @param startPx Where the spine begins, measured down from the row's top: the avatar's bottom edge + * via {@link avatarBottomInRowPx} for a normal row, or the media's height for a row whose left + * column is not an avatar. + */ +export function useThreadParentSpine(startPx: number) { + const rowRef = React.useRef(null); + const branchRef = React.useRef(null); + const [heightPx, setHeightPx] = React.useState(null); + + const measure = React.useCallback(() => { + const row = rowRef.current; + const branch = branchRef.current; + if (!row || !branch) { + setHeightPx(null); + return; + } + setHeightPx(branch.getBoundingClientRect().top - row.getBoundingClientRect().top - startPx); + }, [startPx]); + + React.useLayoutEffect(() => { + measure(); + const row = rowRef.current; + if (row == null || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => measure()); + observer.observe(row); + return () => observer.disconnect(); + }); + + return { rowRef, branchRef, heightPx, topPx: startPx }; +} + +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 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 ThreadListSpine({ + reachPx, + heightPx, + lit, + collapsed, + label, + onToggle, + onFocusBranch, + onPressBranch, + onClearFocus, +}: BranchFocusHandlers & { + /** How far left of the branch's own edge the parent's spine sits. */ + reachPx: number; + heightPx: number | null; + collapsed: boolean; + label: { expand: string; collapse: string }; + onToggle: () => void; +}) { + if (heightPx == null || heightPx <= 0) return null; + + return ( + + ); +} + +/** + * The quarter-turn into the final row of a branch. + * + * `armCenterPx` is where the row's own avatar centre is, which is not always half the reach — a + * claim row's 32px avatar hangs off a debate's 44px keyframe column. + */ +export function ThreadElbow({ reachPx, armCenterPx, lit }: { reachPx: number; armCenterPx: number; lit: boolean }) { + const armY = armCenterPx - 0.5; + const radius = Math.min(9.5, Math.max(0, armY)); + const path = `M 0.5 0 L 0.5 ${armY - radius} Q 0.5 ${armY}, ${0.5 + radius} ${armY} L ${reachPx} ${armY}`; + + return ( + + ); +} + +/** The straight horizontal tick from the spine into a row that is not the last one. */ +export function ThreadArm({ reachPx, armCenterPx, lit }: { reachPx: number; armCenterPx: number; lit: boolean }) { + return ( +
+ ); +} + +/** + * The ⊖ / ⊕ threaded onto a parent's spine, which is how a branch is closed and reopened. + * + * Absolutely positioned when expanded, because it has to sit *on* the descending line — which is + * outside its own row's body box, hence the negative offset the caller computes. Collapsed it takes + * the avatar's place in the row, since there is no line left to sit on. + */ +export function ThreadCollapseToggle({ + collapsed, + leftPx, + label, + onToggle, + onFocusBranch, + onPressBranch, + onClearFocus, +}: Omit & { + collapsed: boolean; + /** Offset from the row's body edge back to the spine. Ignored when collapsed. */ + leftPx?: number; + label: { expand: string; collapse: string }; + onToggle: () => void; +}) { + const shared = { + type: 'button' as const, + 'aria-expanded': !collapsed, + 'aria-label': collapsed ? label.expand : label.collapse, + onClick: onToggle, + }; + + if (collapsed) { + return ( + + ); + } + + return ( + + ); +} + +function noop() {} diff --git a/apps/web/partials/comments/thread-overflow.tsx b/apps/web/partials/comments/thread-overflow.tsx new file mode 100644 index 0000000000..3f02ef3d28 --- /dev/null +++ b/apps/web/partials/comments/thread-overflow.tsx @@ -0,0 +1,127 @@ +'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 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: + * + * * **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}) → + + ); +} + +/** + * 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}{' '} + + + ); +} diff --git a/apps/web/partials/comments/types.ts b/apps/web/partials/comments/types.ts index 97e35cb34f..ad4e3b8f65 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; @@ -53,5 +55,29 @@ 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'; + +/** + * 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; + /** The entity whose votes rank this row, and the space they were cast in. */ + entityId: string; + spaceId: string; + content: React.ReactNode; +} diff --git a/apps/web/partials/explore/claim-explore-feed-card.tsx b/apps/web/partials/explore/claim-explore-feed-card.tsx index 8b708be5d1..7d7c48e81d 100644 --- a/apps/web/partials/explore/claim-explore-feed-card.tsx +++ b/apps/web/partials/explore/claim-explore-feed-card.tsx @@ -166,7 +166,17 @@ 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; + // The activity total counts debates and extracted claims as well as comments, so the live comment + // list replaces only its own share of it — see `useCommentCount`. A card falling back to the plain + // comment count passes nothing and keeps the old behaviour, because there the list really is the + // same measurement. + const commentsInCount = item.activityCount != null ? item.activityCommentCount : undefined; + const liveCommentCount = useCommentCount(item.entityId, activityCount, { commentsInSeed: commentsInCount }); // 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 — @@ -325,10 +335,11 @@ export function ClaimExploreFeedCard({ positionRowEndSlot={ liveCommentCount > 0 ? ( ) : undefined