From 15f88586c78129a6f1a60314d9a5fd9ff673e237 Mon Sep 17 00:00:00 2001 From: sktbrd Date: Fri, 14 Aug 2026 11:53:23 -0300 Subject: [PATCH 1/6] feat(proposals): pure tab model for the proposal detail page Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 0aa9594c4aa5526236ea98fad522b31f8af5dd38) --- src/lib/proposal-tabs.test.ts | 70 +++++++++++++++++++++++++++++++++++ src/lib/proposal-tabs.ts | 49 ++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 src/lib/proposal-tabs.test.ts create mode 100644 src/lib/proposal-tabs.ts diff --git a/src/lib/proposal-tabs.test.ts b/src/lib/proposal-tabs.test.ts new file mode 100644 index 000000000..5afb48bdf --- /dev/null +++ b/src/lib/proposal-tabs.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' + +import { + availableProposalTabs, + parseProposalTab, + PROPOSAL_TAB_KEYS, + PROPOSAL_TAB_LABELS, +} from './proposal-tabs' + +describe('availableProposalTabs', () => { + it('lists all four tabs in reading order when propdates are supported', () => { + expect(availableProposalTabs({ propdatesSupported: true })).toEqual([ + 'proposal', + 'transactions', + 'votes', + 'propdates', + ]) + }) + + it('drops the propdates tab on chains without EAS support', () => { + expect(availableProposalTabs({ propdatesSupported: false })).toEqual([ + 'proposal', + 'transactions', + 'votes', + ]) + }) +}) + +describe('parseProposalTab', () => { + const all = availableProposalTabs({ propdatesSupported: true }) + const noPropdates = availableProposalTabs({ propdatesSupported: false }) + + it('returns a valid tab unchanged', () => { + expect(parseProposalTab('votes', all)).toBe('votes') + }) + + it('falls back to the first tab when the param is absent', () => { + expect(parseProposalTab(null, all)).toBe('proposal') + expect(parseProposalTab(undefined, all)).toBe('proposal') + expect(parseProposalTab('', all)).toBe('proposal') + }) + + it('falls back to the first tab for an unknown value', () => { + expect(parseProposalTab('bogus', all)).toBe('proposal') + }) + + it('normalises case and surrounding whitespace', () => { + expect(parseProposalTab('VOTES', all)).toBe('votes') + expect(parseProposalTab(' Propdates ', all)).toBe('propdates') + }) + + it('rejects a tab that is valid but not currently available', () => { + expect(parseProposalTab('propdates', noPropdates)).toBe('proposal') + }) + + it('falls back to the canonical first key when nothing is available', () => { + expect(parseProposalTab('votes', [])).toBe('proposal') + }) +}) + +describe('PROPOSAL_TAB_LABELS', () => { + it('labels every tab key in English', () => { + expect(PROPOSAL_TAB_KEYS.map((k) => PROPOSAL_TAB_LABELS[k])).toEqual([ + 'Proposal', + 'Transactions', + 'Votes', + 'Propdates', + ]) + }) +}) diff --git a/src/lib/proposal-tabs.ts b/src/lib/proposal-tabs.ts new file mode 100644 index 000000000..a97debd1f --- /dev/null +++ b/src/lib/proposal-tabs.ts @@ -0,0 +1,49 @@ +/** + * Pure tab model for the proposal detail page. Kept out of the React tree so + * the "which tabs exist" and "what does ?tab= resolve to" rules are unit + * testable under the repo's node-environment vitest setup. + */ + +export const PROPOSAL_TAB_KEYS = [ + 'proposal', + 'transactions', + 'votes', + 'propdates', +] as const + +export type ProposalTabKey = (typeof PROPOSAL_TAB_KEYS)[number] + +export const PROPOSAL_TAB_LABELS: Record = { + proposal: 'Proposal', + transactions: 'Transactions', + votes: 'Votes', + propdates: 'Propdates', +} + +/** + * Tabs to render, in reading order. Propdates are attestation-backed, so the + * tab only exists on chains where EAS is deployed. + */ +export function availableProposalTabs({ + propdatesSupported, +}: { + propdatesSupported: boolean +}): ProposalTabKey[] { + return PROPOSAL_TAB_KEYS.filter((key) => key !== 'propdates' || propdatesSupported) +} + +/** + * Resolve a `?tab=` value to a tab that is actually rendered. Unknown values, + * and tabs hidden on this chain, fall back to the first available tab so a + * stale or hand-edited link still lands somewhere useful. + */ +export function parseProposalTab( + raw: string | null | undefined, + available: readonly ProposalTabKey[] +): ProposalTabKey { + const fallback = available[0] ?? PROPOSAL_TAB_KEYS[0] + if (!raw) return fallback + const normalised = raw.trim().toLowerCase() + const match = available.find((key) => key === normalised) + return match ?? fallback +} From 4c1754e62266a58be91dcd9cce47b6aca1f3416e Mon Sep 17 00:00:00 2001 From: sktbrd Date: Fri, 14 Aug 2026 11:56:27 -0300 Subject: [PATCH 2/6] feat(proposals): add ProposalTabs client shell (cherry picked from commit 40bbbb9e17b395971ddb100effce62af75228c96) --- src/components/dao/ProposalTabs.tsx | 153 ++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/components/dao/ProposalTabs.tsx diff --git a/src/components/dao/ProposalTabs.tsx b/src/components/dao/ProposalTabs.tsx new file mode 100644 index 000000000..ed887e9c6 --- /dev/null +++ b/src/components/dao/ProposalTabs.tsx @@ -0,0 +1,153 @@ +'use client' + +import { type ReactNode, useCallback, useEffect, 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> + panels: Partial> +} + +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. + */ +export function ProposalTabs({ propdatesSupported, counts, panels }: Props) { + const available = useMemo( + () => availableProposalTabs({ propdatesSupported }), + [propdatesSupported] + ) + const [active, setActive] = useState(() => available[0]) + const [mounted, setMounted] = useState(() => [available[0]]) + const tabRefs = useRef>>({}) + + // Adopt the deep-linked tab after hydration, and follow back/forward. + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setActive(readTabFromLocation(available)) + const onPopState = () => setActive(readTabFromLocation(available)) + window.addEventListener('popstate', onPopState) + return () => window.removeEventListener('popstate', onPopState) + }, [available]) + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setMounted((prev) => (prev.includes(active) ? prev : [...prev, active])) + }, [active]) + + const selectTab = useCallback( + (key: ProposalTabKey) => { + setActive(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] + ) + + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + 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 ( +
+
+ {available.map((key) => { + const isActive = key === active + const count = counts[key] + return ( + + ) + })} +
+ + {available.map((key) => + mounted.includes(key) ? ( + + ) : null + )} +
+ ) +} From b84ffcf09355d938e6113baa19c2838707f21967 Mon Sep 17 00:00:00 2001 From: sktbrd Date: Fri, 14 Aug 2026 12:01:24 -0300 Subject: [PATCH 3/6] feat(proposals): tab the description, transactions, votes and propdates (cherry picked from commit 333e6a49fc1d5fbbaab15daa4e2df565c072fbca) --- src/components/dao/ProposalDetailView.tsx | 85 ++++++++++------------- 1 file changed, 38 insertions(+), 47 deletions(-) diff --git a/src/components/dao/ProposalDetailView.tsx b/src/components/dao/ProposalDetailView.tsx index 5290c1698..ffbf17db1 100644 --- a/src/components/dao/ProposalDetailView.tsx +++ b/src/components/dao/ProposalDetailView.tsx @@ -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' @@ -118,53 +119,43 @@ export function ProposalDetailView({ detail }: { detail: ProposalDetail }) { quorum={p.quorum} /> -
-

Description

- {description ? ( - {description} - ) : ( -
(No description provided.)
- )} -
- -
-

- Transactions - - {transactions.length} - -

- ({ - target: t.target, - calldata: t.calldata, - valueWei: t.valueWei, - }))} - daoTokenAddress={daoConfig.addresses.token} - nftImages={detail.nftImages} - /> -
- -
-

- Votes - - {detail.votes.length} - -

- -
- - {showPropdates ? ( -
-

Propdates

- -
- ) : null} + + {description} + + ) : ( +
(No description provided.)
+ ), + transactions: ( + ({ + target: t.target, + calldata: t.calldata, + valueWei: t.valueWei, + }))} + daoTokenAddress={daoConfig.addresses.token} + nftImages={detail.nftImages} + /> + ), + votes: ( + + ), + propdates: showPropdates ? ( + + ) : undefined, + }} + /> {showVotePanel && ( From 758470c37c8aa01427acd623da149ded3ba35d72 Mon Sep 17 00:00:00 2001 From: sktbrd Date: Fri, 14 Aug 2026 12:04:45 -0300 Subject: [PATCH 4/6] feat(propdates): collapse older updates, expand the newest (cherry picked from commit 16d209e3e92a6e24a452b38f00f69b95e333cb70) --- src/components/propdates/PropdateCard.tsx | 91 +++++++++++++++------ src/components/propdates/PropdateThread.tsx | 3 +- 2 files changed, 68 insertions(+), 26 deletions(-) diff --git a/src/components/propdates/PropdateCard.tsx b/src/components/propdates/PropdateCard.tsx index 563427f4b..b1d72110d 100644 --- a/src/components/propdates/PropdateCard.tsx +++ b/src/components/propdates/PropdateCard.tsx @@ -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' @@ -15,6 +17,8 @@ type Props = { replies?: PropDate[] isReplying: boolean onReplyClick: (propdate: PropDate) => void + /** The newest update opens expanded; older ones start collapsed. */ + defaultExpanded?: boolean } export function PropdateCard({ @@ -22,46 +26,83 @@ export function PropdateCard({ 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(() => { + setExpanded((prev) => { + // 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 (prev && isReplying) onReplyClick(propdate) + return !prev + }) + }, [isReplying, onReplyClick, propdate]) + return ( -
-
+
+
+ {repliesSorted.length > 0 && ( + + {repliesSorted.length} {repliesSorted.length === 1 ? 'reply' : 'replies'} + + )} + - {propdate.message ? ( -
- {propdate.message} -
- ) : null} + ) diff --git a/src/components/propdates/PropdateThread.tsx b/src/components/propdates/PropdateThread.tsx index 55158f4e6..a6aaf5055 100644 --- a/src/components/propdates/PropdateThread.tsx +++ b/src/components/propdates/PropdateThread.tsx @@ -151,13 +151,14 @@ export function PropdateThread({ proposalIdHash }: Props) { ) : null}
- {topLevel.map((pd) => ( + {topLevel.map((pd, index) => (
{replyingTo?.id === pd.id ? (
From a9e74cfadfd211c724d7bc6786ad68c2c8689eb0 Mon Sep 17 00:00:00 2001 From: sktbrd Date: Fri, 14 Aug 2026 12:15:42 -0300 Subject: [PATCH 5/6] fix(propdates): hoist reply-cancel side effect out of setExpanded updater React StrictMode double-invokes state updater functions in dev; the previous toggle() called onReplyClick(propdate) inside the setExpanded updater, which is a side effect on another component's state. Read `expanded` from the closure instead and fire the side effect before calling setExpanded, so it runs exactly once per click. (cherry picked from commit 57e33f00b5b20b8351e142a0f4b0081056e05315) --- src/components/propdates/PropdateCard.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/components/propdates/PropdateCard.tsx b/src/components/propdates/PropdateCard.tsx index b1d72110d..e3e9bb1a1 100644 --- a/src/components/propdates/PropdateCard.tsx +++ b/src/components/propdates/PropdateCard.tsx @@ -37,13 +37,11 @@ export function PropdateCard({ ) const toggle = useCallback(() => { - setExpanded((prev) => { - // 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 (prev && isReplying) onReplyClick(propdate) - return !prev - }) - }, [isReplying, onReplyClick, propdate]) + // 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 (
From fc31c3a59f6902e4e25093f40cdf11575f5f1f84 Mon Sep 17 00:00:00 2001 From: sktbrd Date: Fri, 14 Aug 2026 12:43:39 -0300 Subject: [PATCH 6/6] fix(ProposalTabs): unique ids, atomic tab activation, doc cost note - Derive tab/panel DOM ids from useId() instead of hardcoded strings, so multiple ProposalTabs instances on one page (e.g. the dev proposal state matrix) don't collide on aria-controls/aria-labelledby. - Mark a tab as mounted in the same state update as making it active (via a shared `activate` helper) instead of a separate effect, which removed a one-frame empty-panel glitch on first activation. - Note in the doc comment that the Propdates SWR poll keeps running for the rest of the session once that tab has been opened. (cherry picked from commit f370aa068527531454d69760d0f9ae3f368a4853) --- src/components/dao/ProposalTabs.tsx | 46 +++++++++++++++++++---------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/components/dao/ProposalTabs.tsx b/src/components/dao/ProposalTabs.tsx index ed887e9c6..7d2574ac5 100644 --- a/src/components/dao/ProposalTabs.tsx +++ b/src/components/dao/ProposalTabs.tsx @@ -1,6 +1,14 @@ 'use client' -import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + type ReactNode, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react' import { availableProposalTabs, @@ -31,7 +39,9 @@ function readTabFromLocation(available: readonly ProposalTabKey[]): ProposalTabK * 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. + * 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( @@ -41,24 +51,28 @@ export function ProposalTabs({ propdatesSupported, counts, panels }: Props) { const [active, setActive] = useState(() => available[0]) const [mounted, setMounted] = useState(() => [available[0]]) const tabRefs = useRef>>({}) + 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 - setActive(readTabFromLocation(available)) - const onPopState = () => setActive(readTabFromLocation(available)) + activate(readTabFromLocation(available)) + const onPopState = () => activate(readTabFromLocation(available)) window.addEventListener('popstate', onPopState) return () => window.removeEventListener('popstate', onPopState) - }, [available]) - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setMounted((prev) => (prev.includes(active) ? prev : [...prev, active])) - }, [active]) + }, [available, activate]) const selectTab = useCallback( (key: ProposalTabKey) => { - setActive(key) + activate(key) const params = new URLSearchParams(window.location.search) if (key === available[0]) params.delete('tab') else params.set('tab', key) @@ -69,7 +83,7 @@ export function ProposalTabs({ propdatesSupported, counts, panels }: Props) { query ? `${window.location.pathname}?${query}` : window.location.pathname ) }, - [available] + [available, activate] ) const onKeyDown = useCallback( @@ -109,9 +123,9 @@ export function ProposalTabs({ propdatesSupported, counts, panels }: Props) { }} type="button" role="tab" - id={`proposal-tab-${key}`} + id={`${uid}-tab-${key}`} aria-selected={isActive} - aria-controls={`proposal-panel-${key}`} + aria-controls={`${uid}-panel-${key}`} tabIndex={isActive ? 0 : -1} onClick={() => selectTab(key)} className={cn( @@ -138,8 +152,8 @@ export function ProposalTabs({ propdatesSupported, counts, panels }: Props) {