diff --git a/causestarter/TODO.md b/causestarter/TODO.md index 1d0899fc..e739bad8 100644 --- a/causestarter/TODO.md +++ b/causestarter/TODO.md @@ -11,7 +11,7 @@ open **if they stay listed here**. - [x] **Cluster-page mediator opt-in** and **statement-level triples** (`/bridge/triple`) — [ADR 0012](/specs/decisions/0012-mediator-is-an-address.md). -- [ ] **Content contracts on the cause board — leftover after first slice.** +- [x] **Content contracts on the cause board — leftover after first slice.** Product rule (settled): list the *contract* (not individual posts) on the cause project list when any post in that contract has a current positive content attestation to a published plank. Dedup by address with vouched diff --git a/ui/src/causestarter/hooks/useCauseProjects.test.tsx b/ui/src/causestarter/hooks/useCauseProjects.test.tsx index 040d710c..b6381f39 100644 --- a/ui/src/causestarter/hooks/useCauseProjects.test.tsx +++ b/ui/src/causestarter/hooks/useCauseProjects.test.tsx @@ -18,6 +18,7 @@ vi.mock('@ui/content-funding', async () => { return { ...actual, useContentFundingState: () => contentState, + useUnmaterializedProspectiveRoundAddresses: () => [], } }) diff --git a/ui/src/causestarter/hooks/useCauseProjects.ts b/ui/src/causestarter/hooks/useCauseProjects.ts index 55325499..11ecf09c 100644 --- a/ui/src/causestarter/hooks/useCauseProjects.ts +++ b/ui/src/causestarter/hooks/useCauseProjects.ts @@ -20,7 +20,11 @@ import { type AlignedProjectFundingTotals, } from '@commonality/sdk/fundingportals' import { ETH_CURRENCY, type Currency, type IpfsCidV1 } from '@commonality/sdk/utils' -import { selectAlignedContentContracts, useContentFundingState } from '@ui/content-funding' +import { + selectAlignedContentContracts, + useContentFundingState, + useUnmaterializedProspectiveRoundAddresses, +} from '@ui/content-funding' import { useTrustedContentAttesters } from '@ui/shared' import { mapWithConcurrency, PLANK_QUERY_CONCURRENCY } from '../lib/concurrency' import { useMachinery } from '../../shared' @@ -78,6 +82,11 @@ export function useCauseProjects( loading: contentLoading, } = useContentFundingState() const trustedContentAttesters = useTrustedContentAttesters() + const unmaterializedProspective = useUnmaterializedProspectiveRoundAddresses() + const unmaterializedReady = unmaterializedProspective !== undefined + const unmaterializedKey = unmaterializedReady + ? unmaterializedProspective.slice().sort().join('\0') + : null const contentTrustKey = trustedContentAttesters .map((entry) => entry.address.toLowerCase()) .sort() @@ -166,12 +175,15 @@ export function useCauseProjects( } } - const contentContracts = selectAlignedContentContracts( - channels, - contentAttestations, - cids, - contentTrustKey ? contentTrustKey.split('\0') : undefined, - ) + const contentContracts = unmaterializedReady + ? selectAlignedContentContracts( + channels, + contentAttestations, + cids, + contentTrustKey ? contentTrustKey.split('\0') : undefined, + unmaterializedKey ? unmaterializedKey.split('\0') : [], + ) + : [] for (const contract of contentContracts) { const key = contract.contractAddress.toLowerCase() const existing = byAddress.get(key) @@ -234,6 +246,8 @@ export function useCauseProjects( contentAttestationsKey, contentTrustKey, contentLoading, + unmaterializedReady, + unmaterializedKey, ]) const countByPlankCid = useMemo(() => { diff --git a/ui/src/causestarter/pages/CauseDetailPage.tsx b/ui/src/causestarter/pages/CauseDetailPage.tsx index 3b144fd0..12e6a07b 100644 --- a/ui/src/causestarter/pages/CauseDetailPage.tsx +++ b/ui/src/causestarter/pages/CauseDetailPage.tsx @@ -31,7 +31,7 @@ import { RosterHistory } from '../components/RosterHistory' import { RosterPublishPanel } from '../components/RosterPublishPanel' import { SafetyRejectionDialog } from '../components/SafetyRejectionDialog' import { - bookmarkCause, causeEditPath, causeFundingPath, causeLeaderboardPath, causeMediatorPath, + bookmarkCause, causeContentBoardPath, causeEditPath, causeFundingPath, causeLeaderboardPath, causeMediatorPath, causePath, causeTitle, findCauseByStable, getCause, isCauseBookmarked, isLive, markPlankPublished, markRosterPublished, newPlank, publishedPlanks, realPlanks, @@ -1190,6 +1190,11 @@ export function CauseDetailPage({ editMode = false }: { editMode?: boolean }) { to: '/content/new', variant: 'outlined', }, + { + label: 'Attested posts', + to: causeContentBoardPath(cause), + variant: 'outlined', + }, ]} projectsHelp={ diff --git a/ui/src/content-funding/hooks/useUnmaterializedProspectiveRoundAddresses.test.ts b/ui/src/content-funding/hooks/useUnmaterializedProspectiveRoundAddresses.test.ts new file mode 100644 index 00000000..754ca74b --- /dev/null +++ b/ui/src/content-funding/hooks/useUnmaterializedProspectiveRoundAddresses.test.ts @@ -0,0 +1,63 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useUnmaterializedProspectiveRoundAddresses } from './useUnmaterializedProspectiveRoundAddresses' + +vi.mock('@commonality/sdk/content-funding', async () => { + const actual = await vi.importActual( + '@commonality/sdk/content-funding', + ) + return { + ...actual, + getProspectiveRounds: vi.fn(), + } +}) + +vi.mock('../../shared', async () => { + const actual = await vi.importActual('../../shared') + return { ...actual, useMachinery: () => ({}) } +}) + +import { getProspectiveRounds } from '@commonality/sdk/content-funding' + +describe('useUnmaterializedProspectiveRoundAddresses', () => { + beforeEach(() => { + vi.mocked(getProspectiveRounds).mockResolvedValue([ + { + round: '0x1111111111111111111111111111111111111111', + channelIdHash: '0x00', + receiptToken: '0x2222222222222222222222222222222222222222', + receiptTokenId: 0n, + condition: '0x3333333333333333333333333333333333333333', + materializedToken: null, + content: [], + }, + { + round: '0x4444444444444444444444444444444444444444', + channelIdHash: '0x00', + receiptToken: '0x5555555555555555555555555555555555555555', + receiptTokenId: 0n, + condition: '0x6666666666666666666666666666666666666666', + materializedToken: '0x7777777777777777777777777777777777777777', + content: [], + }, + ] as Awaited>) + }) + + it('returns only rounds that have not materialized', async () => { + const { result } = renderHook(() => useUnmaterializedProspectiveRoundAddresses()) + expect(result.current).toBeUndefined() + await waitFor(() => { + expect(result.current).toEqual(['0x1111111111111111111111111111111111111111']) + }) + }) + + it('stays undefined when the query fails so consumers fail closed', async () => { + vi.mocked(getProspectiveRounds).mockRejectedValueOnce(new Error('rpc down')) + const { result } = renderHook(() => useUnmaterializedProspectiveRoundAddresses()) + expect(result.current).toBeUndefined() + await waitFor(() => { + expect(getProspectiveRounds).toHaveBeenCalled() + }) + expect(result.current).toBeUndefined() + }) +}) diff --git a/ui/src/content-funding/hooks/useUnmaterializedProspectiveRoundAddresses.ts b/ui/src/content-funding/hooks/useUnmaterializedProspectiveRoundAddresses.ts new file mode 100644 index 00000000..5e30f814 --- /dev/null +++ b/ui/src/content-funding/hooks/useUnmaterializedProspectiveRoundAddresses.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react' +import { getProspectiveRounds } from '@commonality/sdk/content-funding' +import type { SDKMachinery } from '@commonality/sdk/machinery' +import { useMachinery } from '../../shared' + +/** Unmaterialized prospective-round addresses, or `undefined` until the fetch succeeds. */ +export function useUnmaterializedProspectiveRoundAddresses(): string[] | undefined { + const machinery = useMachinery() + const [addresses, setAddresses] = useState(undefined) + + useEffect(() => { + let cancelled = false + void loadUnmaterializedProspectiveRoundAddresses(machinery) + .then((next) => { + if (!cancelled) setAddresses(next) + }) + .catch(() => { + // Leave undefined so consumers fail closed (no post-attestation rows). + }) + return () => { + cancelled = true + } + }, [machinery]) + + return addresses +} + +const inflight = new WeakMap>() + +function loadUnmaterializedProspectiveRoundAddresses(machinery: SDKMachinery): Promise { + const existing = inflight.get(machinery) + if (existing) return existing + const pending = getProspectiveRounds(machinery).then((rounds) => + rounds + .filter((round) => !round.materializedToken) + .map((round) => round.round.toLowerCase()), + ) + inflight.set(machinery, pending) + void pending + .catch(() => undefined) + .finally(() => { + if (inflight.get(machinery) === pending) inflight.delete(machinery) + }) + return pending +} diff --git a/ui/src/content-funding/index.ts b/ui/src/content-funding/index.ts index 5d9557ca..9344f3dd 100644 --- a/ui/src/content-funding/index.ts +++ b/ui/src/content-funding/index.ts @@ -18,6 +18,7 @@ export { ContentFundingProjectSection } from './components/ContentFundingProject export { useClaimFlow } from './hooks/useClaimFlow' export { useContentFundingState } from './hooks/useContentFundingState' +export { useUnmaterializedProspectiveRoundAddresses } from './hooks/useUnmaterializedProspectiveRoundAddresses' export type { ContentAttestationInfo } from './hooks/useContentFundingState' export { selectAlignedContentContracts, diff --git a/ui/src/content-funding/selectAlignedContent.test.ts b/ui/src/content-funding/selectAlignedContent.test.ts index abae4aef..35ec8389 100644 --- a/ui/src/content-funding/selectAlignedContent.test.ts +++ b/ui/src/content-funding/selectAlignedContent.test.ts @@ -82,6 +82,25 @@ describe('selectAlignedContentContracts', () => { expect(rows).toHaveLength(1) }) + it('omits excluded addresses so unmaterialized prospective rounds stay off the post-attestation path', () => { + const rows = selectAlignedContentContracts( + [channel()], + new Map([ + ['twitter:uid:1:111', [{ + canonicalId: 'twitter:uid:1:111', + subjectId: 'x', + attested: true, + attester: '0x1', + statementCid: STATEMENT, + }]], + ]), + [STATEMENT], + undefined, + ['0xABC'], + ) + expect(rows).toEqual([]) + }) + it('treats an empty trust set as unfiltered', () => { const rows = selectAlignedContentContracts( [channel()], diff --git a/ui/src/content-funding/selectAlignedContent.ts b/ui/src/content-funding/selectAlignedContent.ts index af02f818..2a4965d8 100644 --- a/ui/src/content-funding/selectAlignedContent.ts +++ b/ui/src/content-funding/selectAlignedContent.ts @@ -105,12 +105,17 @@ export function selectAlignedContentContracts( attestations: Map, statementCids: readonly string[], trustedAttesters?: Iterable, + excludeAddresses?: Iterable, ): AlignedContentContract[] { + const excluded = new Set( + [...(excludeAddresses ?? [])].map((address) => address.toLowerCase()).filter(Boolean), + ) const items = alignedItemsForStatements(channels, attestations, statementCids, trustedAttesters) const byAddress = new Map() for (const item of items) { const key = item.contractAddress.toLowerCase() + if (excluded.has(key)) continue const existing = byAddress.get(key) if (existing) { existing.alignedItemCount += 1 diff --git a/ui/src/fundingportals/components/AlignedProjectsList.test.tsx b/ui/src/fundingportals/components/AlignedProjectsList.test.tsx index 7bf51581..22cdb233 100644 --- a/ui/src/fundingportals/components/AlignedProjectsList.test.tsx +++ b/ui/src/fundingportals/components/AlignedProjectsList.test.tsx @@ -98,6 +98,7 @@ vi.mock('../../content-funding', async () => { contentAttestations: new Map(), loading: false, })), + useUnmaterializedProspectiveRoundAddresses: () => [], } }) diff --git a/ui/src/fundingportals/components/AlignedProjectsList.tsx b/ui/src/fundingportals/components/AlignedProjectsList.tsx index ad029838..39ea0cc4 100644 --- a/ui/src/fundingportals/components/AlignedProjectsList.tsx +++ b/ui/src/fundingportals/components/AlignedProjectsList.tsx @@ -29,7 +29,11 @@ import { useTrustedSet, TrustNetworkRefreshIndicator, } from '../../shared' -import { selectAlignedContentContracts, useContentFundingState } from '../../content-funding' +import { + selectAlignedContentContracts, + useContentFundingState, + useUnmaterializedProspectiveRoundAddresses, +} from '../../content-funding' import { getProjectStatus } from '../../lazy-giving' import { AlignedProjectCard, @@ -103,6 +107,11 @@ export function AlignedProjectsList({ const machinery = useMachinery() const { address } = useAccount() const { channels, contentAttestations } = useContentFundingState() + const unmaterializedProspective = useUnmaterializedProspectiveRoundAddresses() + const unmaterializedReady = unmaterializedProspective !== undefined + const unmaterializedKey = unmaterializedReady + ? unmaterializedProspective.slice().sort().join('\0') + : null const trustedContentAttesters = useTrustedContentAttesters() const contentTrustKey = trustedContentAttesters .map((entry) => entry.address.toLowerCase()) @@ -189,12 +198,15 @@ export function AlignedProjectsList({ const aligned = perPlank.flat() if (cancelled) return - const contentRows = selectAlignedContentContracts( - channels, - contentAttestations, - loadCids, - contentTrustKey ? contentTrustKey.split('\0') : undefined, - ).map((contract) => ({ + const contentRows = (unmaterializedReady + ? selectAlignedContentContracts( + channels, + contentAttestations, + loadCids, + contentTrustKey ? contentTrustKey.split('\0') : undefined, + unmaterializedKey ? unmaterializedKey.split('\0') : [], + ) + : []).map((contract) => ({ projectAddress: contract.contractAddress, alignmentType: 'direct' as const, fundingCurrency: contract.fundingCurrency ?? ETH_CURRENCY, @@ -259,6 +271,8 @@ export function AlignedProjectsList({ contentAttestationsKey, contentTrustKey, inclusionRules, + unmaterializedReady, + unmaterializedKey, ]) const effectiveStatus = statusFilterLock ?? statusFilter diff --git a/ui/src/fundingportals/components/CauseBoard.test.tsx b/ui/src/fundingportals/components/CauseBoard.test.tsx index 538d5676..1b491c46 100644 --- a/ui/src/fundingportals/components/CauseBoard.test.tsx +++ b/ui/src/fundingportals/components/CauseBoard.test.tsx @@ -75,6 +75,7 @@ vi.mock('../../content-funding', async () => { contentAttestations: new Map(), loading: false, })), + useUnmaterializedProspectiveRoundAddresses: () => [], } }) diff --git a/ui/src/fundingportals/components/CauseBoard.tsx b/ui/src/fundingportals/components/CauseBoard.tsx index edccad1a..17eb4328 100644 --- a/ui/src/fundingportals/components/CauseBoard.tsx +++ b/ui/src/fundingportals/components/CauseBoard.tsx @@ -38,7 +38,11 @@ import { useTrustedContentAttesters, TrustNetworkRefreshIndicator, } from '../../shared' -import { selectAlignedContentContracts, useContentFundingState } from '../../content-funding' +import { + selectAlignedContentContracts, + useContentFundingState, + useUnmaterializedProspectiveRoundAddresses, +} from '../../content-funding' import { AlignedProjectsList } from './AlignedProjectsList' import { SuccessfulProjectsTab } from './SuccessfulProjectsTab' import { AttestAlignmentForm } from './AttestAlignmentForm' @@ -208,6 +212,11 @@ export function CauseBoard({ const [title, setTitle] = useState(null) const [summary, setSummary] = useState(null) const { channels, contentAttestations } = useContentFundingState() + const unmaterializedProspective = useUnmaterializedProspectiveRoundAddresses() + const unmaterializedReady = unmaterializedProspective !== undefined + const unmaterializedKey = unmaterializedReady + ? unmaterializedProspective.slice().sort().join('\0') + : null const trustedContentAttesters = useTrustedContentAttesters() const contentTrustKey = trustedContentAttesters .map((entry) => entry.address.toLowerCase()) @@ -311,12 +320,15 @@ export function CauseBoard({ } } } - const contentContracts = selectAlignedContentContracts( - channels, - contentAttestations, - loadCids, - contentTrustKey ? contentTrustKey.split('\0') : undefined, - ) + const contentContracts = unmaterializedReady + ? selectAlignedContentContracts( + channels, + contentAttestations, + loadCids, + contentTrustKey ? contentTrustKey.split('\0') : undefined, + unmaterializedKey ? unmaterializedKey.split('\0') : [], + ) + : [] const union = unionAlignedFundingProjects([...byAddress.values()], contentContracts) const included = rulesForLoad?.geographic ? (await Promise.all(union.map(async (project) => { @@ -414,6 +426,8 @@ export function CauseBoard({ contentAttestationsKey, contentTrustKey, inclusionRulesKey, + unmaterializedReady, + unmaterializedKey, ]) if (preview) {