Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
03ba993
feat(claims): put debates and their extracted claims in the claim's t…
jwalkingjew Sep 23, 2026
9e109ec
fix(claims): show extracted claims on arrival, and rank the thread by…
jwalkingjew Sep 23, 2026
f311224
fix(claims): real comment counts, every extracted claim, and shared t…
jwalkingjew Sep 23, 2026
1dcd6e1
fix(claims): draw a debate's branch the way a comment thread draws it…
jwalkingjew Sep 23, 2026
61dd157
feat(claims): badge a debate commenter with their position on the claim
jwalkingjew Sep 24, 2026
e72731b
feat(claims): four levels of activity, and one count the card and pag…
jwalkingjew Sep 24, 2026
79469c9
fix(claims): land branch connectors on the avatar they point at
jwalkingjew Sep 24, 2026
2e5d4e0
feat(claims): split thread overflow in two, and count depth from the …
jwalkingjew Sep 24, 2026
c32a332
chore(comments): say why the batched count and the connection count b…
jwalkingjew Sep 24, 2026
8feeb27
fix(claims): count the whole activity tree, not the first ten of it
jwalkingjew Sep 24, 2026
55dfd2f
feat(claims): answer, and find out who, without leaving the thread
jwalkingjew Sep 25, 2026
4e4e88d
fix(claims): stop the speaker avatar rendering at its natural size
jwalkingjew Sep 25, 2026
dd06cc6
fix(claims): put the speaker's face back on the name line
jwalkingjew Sep 25, 2026
1587730
fix(claims): badge the claim the comment is under, and put the side f…
jwalkingjew Sep 25, 2026
428e2b3
fix(claims): draw the spine every branch was reaching for
jwalkingjew Sep 25, 2026
240f859
chore: drop screenshots and preview-checking scripts committed by mis…
jwalkingjew Sep 25, 2026
5d2313a
fix(claims): make the debate's byline the link, not its claim text
jwalkingjew Sep 25, 2026
c9fa3c0
fix(claims): close the 12px break under a commented-on claim
jwalkingjew Sep 25, 2026
0b5f055
refactor(claims): remove the copies this feature grew while it was be…
jwalkingjew Sep 25, 2026
0b97a70
fix(claims): four Copilot findings that were real, and why three were…
jwalkingjew Sep 25, 2026
1f8460a
docs(claims): record where the activity total can disagree with the rows
jwalkingjew Sep 25, 2026
f5004a2
fix(claims): undated rows sorted first in Old, and three counting def…
jwalkingjew Sep 25, 2026
cd48700
fix(claims): a failed count aggregate is not an empty debate
jwalkingjew Sep 25, 2026
31a300a
fix(claims): count the reader's comment where the count lives
jwalkingjew Sep 25, 2026
494516f
fix(claims): give the thread's new links back to the browser
jwalkingjew Sep 25, 2026
993fb98
fix(claims): never guess how a claim is answered
jwalkingjew Sep 26, 2026
a0c5a78
Merge branch 'master' into preston/geo-3008-claim-activity-feed
jwalkingjew Sep 26, 2026
7c6e5ce
fix(claims): meet master's one vocabulary, and three optimistic holes
jwalkingjew Sep 26, 2026
cce8597
fix(claims): a cancel as narrow as the write, and a debates read that…
jwalkingjew Sep 26, 2026
4d6337c
Merge remote-tracking branch 'origin/master' into preston/geo-3008-cl…
jwalkingjew Sep 26, 2026
7062ad2
fix(claims): say when a read failed, everywhere the feed draws emptiness
jwalkingjew Sep 26, 2026
56751ea
fix(claims): count a comment once, wherever it was written from
jwalkingjew Sep 26, 2026
97c8009
fix(claims): draw a new comment first, and keep the number it moves m…
jwalkingjew Sep 26, 2026
b6f79c1
Merge branch 'master' into preston/geo-3008-claim-activity-feed
jwalkingjew Sep 26, 2026
7d4dcd0
fix(claims): gate branches on the live comment count, and balance eve…
jwalkingjew Sep 26, 2026
3d6ff60
fix(claims): count a retried comment when its row returns, not when i…
jwalkingjew Sep 26, 2026
9f8e4dd
docs(claims): stop describing the veracity kind as though it still ex…
jwalkingjew Sep 26, 2026
c8f17da
fix(debates): let a timecode go when the link stops asking for one
jwalkingjew Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/web/core/claims/browse/activity-row-tag.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span
className={cx(
// Same geometry as `ResponsePositionTag`, so the two sit on one line without one riding
// higher than the other.
'inline-flex shrink-0 items-center rounded-xs px-1 py-px text-[0.6875rem] font-medium',
'bg-grey-01 text-grey-04'
)}
>
{LABELS[kind]}
</span>
);
}
251 changes: 251 additions & 0 deletions apps/web/core/claims/browse/claim-activity-count.test.ts
Original file line number Diff line number Diff line change
@@ -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<number>) {
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<Map<string, ClaimActivityCount>>(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<string, ClaimActivityCount>) => {};
const inFlight = new Promise<Map<string, ClaimActivityCount>>(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<string, ClaimActivityCount>) => {};
const firstRequest = new Promise<Map<string, ClaimActivityCount>>(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<Map<string, ClaimActivityCount>>(otherKey)?.get(CLAIM)?.total).toBe(7);
});
});
Loading
Loading