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 && ( diff --git a/src/components/dao/ProposalTabs.tsx b/src/components/dao/ProposalTabs.tsx new file mode 100644 index 000000000..7d2574ac5 --- /dev/null +++ b/src/components/dao/ProposalTabs.tsx @@ -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> + 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 — 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(() => 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 + 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) => { + 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 + )} +
+ ) +} diff --git a/src/components/propdates/PropdateCard.tsx b/src/components/propdates/PropdateCard.tsx index 563427f4b..e3e9bb1a1 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,81 @@ 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(() => { + // 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 ( -
-
+
+
+ {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 ? (
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 +}