Skip to content
Open
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
85 changes: 38 additions & 47 deletions src/components/dao/ProposalDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CHAIN_ID } from '@buildeross/types'
import { isChainIdSupportedByEAS } from '@buildeross/utils/eas'

import { ProposalActions } from '@/components/dao/ProposalActions'
import { ProposalTabs } from '@/components/dao/ProposalTabs'
import { ProposalTransactionList } from '@/components/dao/ProposalTransactionList'
import { ProposalVotesList } from '@/components/dao/ProposalVotesList'
import { StatusBadge } from '@/components/dao/StatusBadge'
Expand Down Expand Up @@ -118,53 +119,43 @@ export function ProposalDetailView({ detail }: { detail: ProposalDetail }) {
quorum={p.quorum}
/>

<section className="rounded-xl border border-border bg-surface px-4 py-5 sm:px-6 sm:py-[22px]">
<h3 className="mb-3 text-base font-bold">Description</h3>
{description ? (
<Markdown>{description}</Markdown>
) : (
<div className="text-sm text-muted-fg">(No description provided.)</div>
)}
</section>

<section className="rounded-xl border border-border bg-surface px-4 py-5 sm:px-6 sm:py-[22px]">
<h3 className="mb-3 text-base font-bold">
Transactions
<span className="ml-2 text-[12.5px] font-normal text-muted-fg">
{transactions.length}
</span>
</h3>
<ProposalTransactionList
chainId={daoConfig.chainId}
transactions={transactions.map((t) => ({
target: t.target,
calldata: t.calldata,
valueWei: t.valueWei,
}))}
daoTokenAddress={daoConfig.addresses.token}
nftImages={detail.nftImages}
/>
</section>

<section className="rounded-xl border border-border bg-surface px-4 py-5 sm:px-6 sm:py-[22px]">
<h3 className="mb-3 text-base font-bold">
Votes
<span className="ml-2 text-[12.5px] font-normal text-muted-fg">
{detail.votes.length}
</span>
</h3>
<ProposalVotesList
votes={detail.votes}
proposalIdHash={detail.proposalIdHash}
/>
</section>

{showPropdates ? (
<section className="rounded-xl border border-border bg-surface px-4 py-5 sm:px-6 sm:py-[22px]">
<h3 className="mb-3 text-base font-bold">Propdates</h3>
<PropdateThread proposalIdHash={detail.proposalIdHash} />
</section>
) : null}
<ProposalTabs
propdatesSupported={showPropdates}
counts={{
transactions: transactions.length,
votes: detail.votes.length,
}}
panels={{
proposal: description ? (
<div className="mx-auto max-w-[80ch]">
<Markdown className="max-w-none">{description}</Markdown>
</div>
) : (
<div className="text-sm text-muted-fg">(No description provided.)</div>
),
transactions: (
<ProposalTransactionList
chainId={daoConfig.chainId}
transactions={transactions.map((t) => ({
target: t.target,
calldata: t.calldata,
valueWei: t.valueWei,
}))}
daoTokenAddress={daoConfig.addresses.token}
nftImages={detail.nftImages}
/>
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
votes: (
<ProposalVotesList
votes={detail.votes}
proposalIdHash={detail.proposalIdHash}
/>
),
propdates: showPropdates ? (
<PropdateThread proposalIdHash={detail.proposalIdHash} />
) : undefined,
}}
/>
</div>

{showVotePanel && (
Expand Down
167 changes: 167 additions & 0 deletions src/components/dao/ProposalTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
'use client'

import {
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react'

import {
availableProposalTabs,
parseProposalTab,
PROPOSAL_TAB_LABELS,
type ProposalTabKey,
} from '@/lib/proposal-tabs'
import { cn } from '@/lib/utils'

type Props = {
propdatesSupported: boolean
/** Badge counts known server-side. Tabs without an entry render no badge. */
counts: Partial<Record<ProposalTabKey, number>>
panels: Partial<Record<ProposalTabKey, ReactNode>>
}

function readTabFromLocation(available: readonly ProposalTabKey[]): ProposalTabKey {
const raw = new URLSearchParams(window.location.search).get('tab')
return parseProposalTab(raw, available)
}

/**
* Tab navigation for the proposal detail page.
*
* Panels arrive already rendered from the server component, so this file owns
* only selection. The active tab is mirrored into `?tab=` via
* `window.history.replaceState` — Next's supported shallow update — which keeps
* the prerendered HTML complete (no Suspense fallback) while still yielding a
* shareable link. A panel is mounted the first time it is opened and stays
* mounted afterwards, so `PropdateThread`'s 15s SWR poll never runs for readers
* who don't open that tab, and reopening a tab doesn't refetch — though once the
* Propdates tab has been opened, that poll keeps running for the rest of the
* session even while the tab is hidden.
*/
export function ProposalTabs({ propdatesSupported, counts, panels }: Props) {
const available = useMemo(
() => availableProposalTabs({ propdatesSupported }),
[propdatesSupported]
)
const [active, setActive] = useState<ProposalTabKey>(() => available[0])
const [mounted, setMounted] = useState<ProposalTabKey[]>(() => [available[0]])
const tabRefs = useRef<Partial<Record<ProposalTabKey, HTMLButtonElement | null>>>({})
const uid = useId()

// Activating a tab must mark it mounted in the same update as making it
// active — otherwise there's a frame where the previous panel is already
// hidden and the new one isn't mounted yet, and the panel region collapses.
const activate = useCallback((key: ProposalTabKey) => {
setActive(key)
setMounted((prev) => (prev.includes(key) ? prev : [...prev, key]))
}, [])

// Adopt the deep-linked tab after hydration, and follow back/forward.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
activate(readTabFromLocation(available))
const onPopState = () => activate(readTabFromLocation(available))
window.addEventListener('popstate', onPopState)
return () => window.removeEventListener('popstate', onPopState)
}, [available, activate])

const selectTab = useCallback(
(key: ProposalTabKey) => {
activate(key)
const params = new URLSearchParams(window.location.search)
if (key === available[0]) params.delete('tab')
else params.set('tab', key)
const query = params.toString()
window.history.replaceState(
null,
'',
query ? `${window.location.pathname}?${query}` : window.location.pathname
)
},
[available, activate]
)

const onKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
const index = available.indexOf(active)
let nextIndex = -1
if (event.key === 'ArrowRight') nextIndex = (index + 1) % available.length
else if (event.key === 'ArrowLeft')
nextIndex = (index - 1 + available.length) % available.length
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = available.length - 1
if (nextIndex < 0) return
event.preventDefault()
const nextKey = available[nextIndex]
selectTab(nextKey)
tabRefs.current[nextKey]?.focus()
},
[active, available, selectTab]
)

return (
<div className="flex flex-col gap-4">
<div
role="tablist"
aria-label="Proposal sections"
onKeyDown={onKeyDown}
className="-mx-1 flex items-center gap-1 overflow-x-auto border-b border-border px-1"
>
{available.map((key) => {
const isActive = key === active
const count = counts[key]
return (
<button
key={key}
ref={(node) => {
tabRefs.current[key] = node
}}
type="button"
role="tab"
id={`${uid}-tab-${key}`}
aria-selected={isActive}
aria-controls={`${uid}-panel-${key}`}
tabIndex={isActive ? 0 : -1}
onClick={() => selectTab(key)}
className={cn(
'relative shrink-0 px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors',
isActive ? 'text-fg' : 'text-muted-fg hover:text-fg'
)}
>
{PROPOSAL_TAB_LABELS[key]}
{typeof count === 'number' && (
<span className="ml-1.5 text-[12.5px] font-normal text-muted-fg">
{count}
</span>
)}
{isActive && (
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-sm bg-fg" />
)}
</button>
)
})}
</div>

{available.map((key) =>
mounted.includes(key) ? (
<div
key={key}
role="tabpanel"
id={`${uid}-panel-${key}`}
aria-labelledby={`${uid}-tab-${key}`}
hidden={key !== active}
tabIndex={0}
className="rounded-xl border border-border bg-surface px-4 py-5 sm:px-6 sm:py-[22px]"
>
{panels[key]}
</div>
) : null
)}
</div>
)
}
89 changes: 64 additions & 25 deletions src/components/propdates/PropdateCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

import { type PropDate } from '@buildeross/sdk/subgraph'
import { formatTimeAgo } from '@buildeross/utils'
import { useMemo } from 'react'
import { ChevronRight } from 'lucide-react'
import { useCallback, useId, useMemo, useState } from 'react'

import { ActorIdentity } from '@/components/feed/ActorIdentity'
import { Markdown } from '@/components/Markdown'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'

import { PropdateReply } from './PropdateReply'

Expand All @@ -15,53 +17,90 @@ type Props = {
replies?: PropDate[]
isReplying: boolean
onReplyClick: (propdate: PropDate) => void
/** The newest update opens expanded; older ones start collapsed. */
defaultExpanded?: boolean
}

export function PropdateCard({
propdate,
replies = [],
isReplying,
onReplyClick,
defaultExpanded = false,
}: Props) {
const [expanded, setExpanded] = useState(defaultExpanded)
const bodyId = useId()

const repliesSorted = useMemo(
() => [...replies].sort((a, b) => a.timeCreated - b.timeCreated),
[replies]
)

const toggle = useCallback(() => {
// Collapsing the card that owns the open reply form would leave that form
// stranded below a closed card — PropdateThread renders it as a sibling.
if (expanded && isReplying) onReplyClick(propdate)
setExpanded((prev) => !prev)
}, [expanded, isReplying, onReplyClick, propdate])

return (
<div className="flex flex-col gap-4 rounded-xl border border-border bg-surface px-4 py-5 sm:px-6">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col rounded-xl border border-border bg-surface">
<button
type="button"
onClick={toggle}
aria-expanded={expanded}
aria-controls={bodyId}
className="flex items-center justify-between gap-2 px-4 py-4 text-left sm:px-6"
>
<div className="flex min-w-0 items-center gap-2">
<ChevronRight
aria-hidden
className={cn(
'h-4 w-4 shrink-0 text-muted-fg transition-transform',
expanded && 'rotate-90'
)}
/>
<ActorIdentity address={propdate.creator} size={28} />
<span className="shrink-0 text-[12.5px] text-muted-fg whitespace-nowrap">
· {formatTimeAgo(propdate.timeCreated)}
</span>
</div>
</div>
{repliesSorted.length > 0 && (
<span className="shrink-0 text-[12.5px] text-muted-fg whitespace-nowrap">
{repliesSorted.length} {repliesSorted.length === 1 ? 'reply' : 'replies'}
</span>
)}
</button>

{propdate.message ? (
<div className="rounded-md bg-surface-2 px-4 pt-3 pb-1">
<Markdown>{propdate.message}</Markdown>
</div>
) : null}
<div
id={bodyId}
hidden={!expanded}
className="flex flex-col gap-4 px-4 pb-5 sm:px-6"
>
{propdate.message ? (
<div className="rounded-md bg-surface-2 px-4 pt-3 pb-1">
<Markdown>{propdate.message}</Markdown>
</div>
) : null}

{repliesSorted.length > 0 ? (
<div className="ml-2 border-l-2 border-border pl-4">
{repliesSorted.map((r) => (
<PropdateReply key={r.id} reply={r} />
))}
</div>
) : null}
{repliesSorted.length > 0 ? (
<div className="ml-2 border-l-2 border-border pl-4">
{repliesSorted.map((r) => (
<PropdateReply key={r.id} reply={r} />
))}
</div>
) : null}

<div className="flex justify-end">
<Button
type="button"
variant={isReplying ? 'secondary' : 'outline'}
size="sm"
onClick={() => onReplyClick(propdate)}
>
{isReplying ? 'Cancel reply' : 'Reply'}
</Button>
<div className="flex justify-end">
<Button
type="button"
variant={isReplying ? 'secondary' : 'outline'}
size="sm"
onClick={() => onReplyClick(propdate)}
>
{isReplying ? 'Cancel reply' : 'Reply'}
</Button>
</div>
</div>
</div>
)
Expand Down
Loading
Loading