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
4 changes: 4 additions & 0 deletions causestarter/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ open **if they stay listed here**.

## Product / UX

- [ ] `normalizeSlug` slices to 64 *after* stripping hyphens, so a cut on a hyphen can fail `validateSlug`. Bridge-creator `slugifyCluster` now strips again after slice; align CauseStarter if organizers hit 64-char slugs.

- [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.**
Product rule (settled): list the *contract* (not individual posts) on the
cause project list when any post in that contract has a current positive
Expand Down
2 changes: 2 additions & 0 deletions causestarter/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { HomePage } from './pages/HomePage'
import { StartCauseRedirect } from './pages/StartCauseRedirect'
import { StartBridgeRedirect } from './pages/StartBridgeRedirect'
import { BridgeClusterPage } from './pages/BridgeClusterPage'
import { BridgeTriplePage } from './pages/BridgeTriplePage'
import { CausesPage } from './pages/CausesPage'
import { CauseDetailPage } from './pages/CauseDetailPage'
import { CauseMediatorPage } from './pages/CauseMediatorPage'
Expand Down Expand Up @@ -54,6 +55,7 @@ export default function App() {
{/* No intermediate form — creates a draft and opens the editor. */}
<Route path="/start" element={<StartCauseRedirect />} />
<Route path="/bridge/new" element={<StartBridgeRedirect />} />
<Route path="/bridge/triple" element={<BridgeTriplePage />} />
<Route path="/bridge/:owner/:slugPart" element={<BridgeClusterPage />} />
<Route path="/bridge/:draftId" element={<BridgeClusterPage />} />
<Route path="/causes" element={<CausesPage />} />
Expand Down
12 changes: 12 additions & 0 deletions causestarter/src/components/CauseMediatorCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,21 @@ describe('CauseMediatorCard', () => {
expect(screen.getByText(/published identity is incomplete/)).toBeInTheDocument()
})

it('cannot be enabled without a service URL (featured triples need GET /anchors)', () => {
renderCard({ ...mediator, serviceUrl: '' })

expect(screen.getByTestId('cause-mediator-optin')).toBeDisabled()
expect(screen.getByText(/published identity is incomplete/)).toBeInTheDocument()
})

it('still offers a deep link for clients that cannot toggle in place', () => {
const path = causeMediatorOptInPath(mediator)
expect(path).toContain('nudgerName=Housing+mediator')
expect(path).toContain('nudgerServiceUrl=https%3A%2F%2Fhousing.example%2Fmediator')
expect(path).not.toContain('Common+Sense+Majority')
})

it('does not deep-link an incomplete mediator into settings', () => {
expect(causeMediatorOptInPath({ ...mediator, serviceUrl: '' })).toBe('/settings')
})
})
16 changes: 6 additions & 10 deletions causestarter/src/components/CauseMediatorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import CheckIcon from '@mui/icons-material/Check'
import { Link as RouterLink } from 'react-router-dom'
import {
addTrustedNudger,
getMediatorOptInPath,
isTrustedNudger,
loadTrustedNudgers,
mediatorNudgerFromCause,
serviceMediatorFromCause,
removeTrustedNudger,
} from '@ui/shared'
import type { CauseMediator } from '../lib/causeStore'
Expand All @@ -16,14 +17,9 @@ import type { CauseMediator } from '../lib/causeStore'
* place). CauseStarter reads the same store directly, so its own card toggles.
*/
export function causeMediatorOptInPath(mediator: CauseMediator): string {
const params = new URLSearchParams({
addNudger: mediator.address,
nudgerName: mediator.name,
nudgerDescription: mediator.description,
nudgerServiceUrl: mediator.serviceUrl,
nudgerSourceType: 'bridge-creator',
})
return `/settings?${params.toString()}`
const entry = serviceMediatorFromCause(mediator)
if (!entry) return '/settings'
return getMediatorOptInPath(entry)
}

/**
Expand All @@ -38,7 +34,7 @@ export function CauseMediatorCard({ mediator, detailPath }: {
/** Omitted on the mediator's own page, where the link would point at itself. */
detailPath?: string
}) {
const entry = mediatorNudgerFromCause(mediator)
const entry = serviceMediatorFromCause(mediator)
const [nudgers, setNudgers] = useState(loadTrustedNudgers)
const optedIn = isTrustedNudger(mediator.address, nudgers)

Expand Down
55 changes: 55 additions & 0 deletions causestarter/src/components/ClusterMediatorOptIn.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ClusterMediatorOptIn, clusterMediatorOptInPath } from './ClusterMediatorOptIn'

const fields = {
mediatorAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as const,
mediatorName: 'Ada Mediator',
mediatorNote: 'Hand-authored settlement.',
}

describe('ClusterMediatorOptIn', () => {
beforeEach(() => {
localStorage.clear()
})

afterEach(() => {
cleanup()
})

it('opts in to the mediator address with no service URL', () => {
render(<ClusterMediatorOptIn fields={fields} />)

const button = screen.getByTestId('cluster-mediator-optin')
expect(button).toHaveTextContent('Opt in')
expect(button).not.toBeDisabled()

fireEvent.click(button)
expect(button).toHaveTextContent('Opted in')
const stored = JSON.parse(localStorage.getItem('commonality:trustedNudgers') ?? '[]') as Array<{
address: string
serviceUrl?: string
sourceType?: string
name: string
}>
expect(stored).toHaveLength(1)
expect(stored[0]?.address).toBe(fields.mediatorAddress)
expect(stored[0]?.name).toBe('Ada Mediator')
expect(stored[0]?.serviceUrl).toBeUndefined()
expect(stored[0]?.sourceType).toBeUndefined()
})

it('does not treat opening the cluster as subscribe — starts off', () => {
render(<ClusterMediatorOptIn fields={fields} />)
expect(screen.getByTestId('cluster-mediator-optin')).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByText(/not this page/i)).toBeInTheDocument()
})

it('deep-links to Settings without a service URL', () => {
const path = clusterMediatorOptInPath(fields)
const url = new URL(path, 'https://causestarter.example')
expect(url.searchParams.get('addNudger')).toBe(fields.mediatorAddress)
expect(url.searchParams.get('nudgerName')).toBe('Ada Mediator')
expect(url.searchParams.has('nudgerServiceUrl')).toBe(false)
})
})
85 changes: 85 additions & 0 deletions causestarter/src/components/ClusterMediatorOptIn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useState } from 'react'
import { Button, Paper, Stack, Typography } from '@mui/material'
import CheckIcon from '@mui/icons-material/Check'
import {
addTrustedNudger,
getMediatorOptInPath,
isTrustedNudger,
loadTrustedNudgers,
mediatorNudgerFromCause,
removeTrustedNudger,
} from '@ui/shared'
import type { BridgeClusterFields } from '../lib/bridgeCluster'

const DEFAULT_DESCRIPTION =
'Suggests modified wordings of the causes this mediator bridged. Signing stays your choice.'

export function clusterMediatorEntry(fields: Pick<BridgeClusterFields, 'mediatorAddress' | 'mediatorName' | 'mediatorNote'>) {
return mediatorNudgerFromCause({
address: fields.mediatorAddress,
name: fields.mediatorName,
description: fields.mediatorNote.trim() || DEFAULT_DESCRIPTION,
})
}

export function clusterMediatorOptInPath(fields: Pick<BridgeClusterFields, 'mediatorAddress' | 'mediatorName' | 'mediatorNote'>): string {
const entry = clusterMediatorEntry(fields)
if (!entry) return '/settings'
return getMediatorOptInPath(entry)
}

/**
* Opt in to this cluster's mediator address. No service URL — republish is the tick.
*/
export function ClusterMediatorOptIn({
fields,
}: {
fields: Pick<BridgeClusterFields, 'mediatorAddress' | 'mediatorName' | 'mediatorNote'>
}) {
const entry = clusterMediatorEntry(fields)
const [nudgers, setNudgers] = useState(loadTrustedNudgers)
const optedIn = isTrustedNudger(fields.mediatorAddress, nudgers)

const toggle = () => {
if (!entry) return
setNudgers(optedIn ? removeTrustedNudger(fields.mediatorAddress) : addTrustedNudger(entry))
}

return (
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 3, border: '1px solid', borderColor: 'divider' }}
data-testid="cluster-mediator-optin-card"
>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
alignItems={{ xs: 'flex-start', sm: 'center' }}
justifyContent="space-between"
>
<Stack spacing={0.5} sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Listen to this mediator
</Typography>
<Typography variant="body2" color="text.secondary">
You are opting into <strong>{fields.mediatorName}</strong>'s address, not this page.
Later parent→modified suggestions appear if they publish again. Opening this cluster
does not subscribe you.
</Typography>
</Stack>
<Button
variant={optedIn ? 'outlined' : 'contained'}
size="small"
disabled={!entry}
onClick={toggle}
startIcon={optedIn ? <CheckIcon /> : undefined}
aria-pressed={optedIn}
data-testid="cluster-mediator-optin"
sx={{ textTransform: 'none', borderRadius: 999, flexShrink: 0 }}
>
{optedIn ? 'Opted in' : 'Opt in'}
</Button>
</Stack>
</Paper>
)
}
27 changes: 21 additions & 6 deletions causestarter/src/lib/bridgeNudges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,13 @@ export function buildNudgeBatchDocument(args: {
}
}

export async function publishParentToModifiedNudges(args: {
export async function publishNudgeBatch(args: {
writeClients: WriteClients
mediatorAddress: `0x${string}`
fields: BridgeClusterFields
nudges: ParentToModifiedNudge[]
}): Promise<{ batchCid: string; txHash: `0x${string}` }> {
const nudges = parentToModifiedNudges(args.fields.pairs)
if (nudges.length === 0) {
throw new Error('Add modified→parent pairs first. Nudges are parent-signer → modified plank, and we will not invent them.')
if (args.nudges.length === 0) {
throw new Error('Add parent→modified pairs first. We will not invent them.')
}
const publishedDataAddress = getRuntimeConfigValue('VITE_PUBLISHED_DATA_CONTRACT_ADDRESS') as `0x${string}` | undefined
const nudgePublicationsAddress = getRuntimeConfigValue('VITE_NUDGE_PUBLICATIONS_CONTRACT_ADDRESS') as `0x${string}` | undefined
Expand All @@ -66,7 +65,7 @@ export async function publishParentToModifiedNudges(args: {

const document = buildNudgeBatchDocument({
nudger: args.mediatorAddress,
nudges,
nudges: args.nudges,
})
const content = new TextEncoder().encode(JSON.stringify(document))
const batchCid = publishedDataIdToCid(computePublishedDataId(content))
Expand All @@ -88,3 +87,19 @@ export async function publishParentToModifiedNudges(args: {

return { batchCid, txHash: hashes[hashes.length - 1]! }
}

export async function publishParentToModifiedNudges(args: {
writeClients: WriteClients
mediatorAddress: `0x${string}`
fields: BridgeClusterFields
}): Promise<{ batchCid: string; txHash: `0x${string}` }> {
const nudges = parentToModifiedNudges(args.fields.pairs)
if (nudges.length === 0) {
throw new Error('Add modified→parent pairs first. Nudges are parent-signer → modified plank, and we will not invent them.')
}
return publishNudgeBatch({
writeClients: args.writeClients,
mediatorAddress: args.mediatorAddress,
nudges,
})
}
63 changes: 63 additions & 0 deletions causestarter/src/lib/bridgeTriple.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import {
applyPublishedCids,
emptyTripleDraft,
modifiedToCommonFromTriple,
parentToModifiedFromTriple,
textsToPublish,
validateTripleForPublish,
} from './bridgeTriple'

describe('bridgeTriple', () => {
it('refuses to publish without mediator name, both modifieds, parents, and common ground', () => {
const draft = emptyTripleDraft()
expect(validateTripleForPublish(draft)).toMatch(/mediator/i)
draft.mediatorName = 'Ada'
expect(validateTripleForPublish(draft)).toMatch(/modified/i)
draft.sideA.modifiedText = 'Modified A'
draft.sideB.modifiedText = 'Modified B'
expect(validateTripleForPublish(draft)).toMatch(/parent/i)
draft.sideA.parentText = 'Parent A'
draft.sideB.parentCid = 'bafyparentb'
expect(validateTripleForPublish(draft)).toMatch(/shared ground/i)
draft.commonGroundText = 'Common'
expect(validateTripleForPublish(draft)).toBeNull()
})

it('publishes missing texts and does not republish CIDs', () => {
const draft = emptyTripleDraft()
draft.sideA.parentCid = 'bafyparenta'
draft.sideA.modifiedText = 'Modified A'
draft.sideB.parentText = 'Parent B'
draft.sideB.modifiedCid = 'bafymodb'
draft.commonGroundText = 'Common'
const texts = textsToPublish(draft)
expect(texts.map((item) => item.key)).toEqual(['sideA.modified', 'sideB.parent', 'commonGround'])
})

it('nudges parent → modified, never parent → common ground', () => {
const draft = emptyTripleDraft()
draft.sideA.parentCid = 'bafyparenta'
draft.sideA.modifiedCid = 'bafymoda'
draft.sideB.parentCid = 'bafyparentb'
draft.sideB.modifiedCid = 'bafymodb'
draft.commonGroundCid = 'bafycommon'
expect(parentToModifiedFromTriple(draft)).toEqual([
{ targetStatementCid: 'bafyparenta', suggestedStatementCid: 'bafymoda' },
{ targetStatementCid: 'bafyparentb', suggestedStatementCid: 'bafymodb' },
])
expect(modifiedToCommonFromTriple(draft)).toEqual([
{ fromCid: 'bafymoda', toCid: 'bafycommon' },
{ fromCid: 'bafymodb', toCid: 'bafycommon' },
])
})

it('fills CIDs from a publish pass', () => {
const next = applyPublishedCids(emptyTripleDraft(), {
'sideA.modified': 'bafymoda',
commonGround: 'bafycommon',
})
expect(next.sideA.modifiedCid).toBe('bafymoda')
expect(next.commonGroundCid).toBe('bafycommon')
})
})
Loading
Loading