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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion causestarter/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions ui/src/causestarter/hooks/useCauseProjects.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ vi.mock('@ui/content-funding', async () => {
return {
...actual,
useContentFundingState: () => contentState,
useUnmaterializedProspectiveRoundAddresses: () => [],
}
})

Expand Down
28 changes: 21 additions & 7 deletions ui/src/causestarter/hooks/useCauseProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -234,6 +246,8 @@ export function useCauseProjects(
contentAttestationsKey,
contentTrustKey,
contentLoading,
unmaterializedReady,
unmaterializedKey,
])

const countByPlankCid = useMemo(() => {
Expand Down
7 changes: 6 additions & 1 deletion ui/src/causestarter/pages/CauseDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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={
<Stack spacing={1}>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof import('@commonality/sdk/content-funding')>(
'@commonality/sdk/content-funding',
)
return {
...actual,
getProspectiveRounds: vi.fn(),
}
})

vi.mock('../../shared', async () => {
const actual = await vi.importActual<typeof import('../../shared')>('../../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<ReturnType<typeof getProspectiveRounds>>)
})

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()
})
})
Original file line number Diff line number Diff line change
@@ -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<string[] | undefined>(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<SDKMachinery, Promise<string[]>>()

function loadUnmaterializedProspectiveRoundAddresses(machinery: SDKMachinery): Promise<string[]> {
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
}
1 change: 1 addition & 0 deletions ui/src/content-funding/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions ui/src/content-funding/selectAlignedContent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
Expand Down
5 changes: 5 additions & 0 deletions ui/src/content-funding/selectAlignedContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,17 @@ export function selectAlignedContentContracts(
attestations: Map<string, ContentAttestationInfo[]>,
statementCids: readonly string[],
trustedAttesters?: Iterable<string>,
excludeAddresses?: Iterable<string>,
): AlignedContentContract[] {
const excluded = new Set(
[...(excludeAddresses ?? [])].map((address) => address.toLowerCase()).filter(Boolean),
)
const items = alignedItemsForStatements(channels, attestations, statementCids, trustedAttesters)
const byAddress = new Map<string, AlignedContentContract>()

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ vi.mock('../../content-funding', async () => {
contentAttestations: new Map(),
loading: false,
})),
useUnmaterializedProspectiveRoundAddresses: () => [],
}
})

Expand Down
28 changes: 21 additions & 7 deletions ui/src/fundingportals/components/AlignedProjectsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -259,6 +271,8 @@ export function AlignedProjectsList({
contentAttestationsKey,
contentTrustKey,
inclusionRules,
unmaterializedReady,
unmaterializedKey,
])

const effectiveStatus = statusFilterLock ?? statusFilter
Expand Down
1 change: 1 addition & 0 deletions ui/src/fundingportals/components/CauseBoard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ vi.mock('../../content-funding', async () => {
contentAttestations: new Map(),
loading: false,
})),
useUnmaterializedProspectiveRoundAddresses: () => [],
}
})

Expand Down
28 changes: 21 additions & 7 deletions ui/src/fundingportals/components/CauseBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -208,6 +212,11 @@ export function CauseBoard({
const [title, setTitle] = useState<string | null>(null)
const [summary, setSummary] = useState<string | null>(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())
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -414,6 +426,8 @@ export function CauseBoard({
contentAttestationsKey,
contentTrustKey,
inclusionRulesKey,
unmaterializedReady,
unmaterializedKey,
])

if (preview) {
Expand Down
Loading