Repository navigation
fix: red dot next to governance tab - #1692
webmagic123 wants to merge 14 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
53172b0 to
7fb302e
Compare
There was a problem hiding this comment.
Pull request overview
Adds a “pending votes” indicator to the Browse sidebar’s Governance link by extending the sidebar data payload with a hasPendingVotes boolean derived from governance proposal data.
Changes:
- Add
hasPendingVotestoBrowseSidebarDataand propagate it through sidebar loading. - Fetch proposed governance proposals across editor spaces and compute whether any are unvoted by the current member.
- Render a small red dot next to “Governance” when pending votes exist.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| apps/web/partials/browse-sidebar/browse-sidebar.tsx | Renders a red-dot indicator on the Governance nav item when hasPendingVotes is true. |
| apps/web/core/browse/fetch-browse-sidebar-data.ts | Extends sidebar data fetch to compute hasPendingVotes via REST proposal pagination across editor spaces. |
| apps/web/app/explore/page.tsx | Updates degraded/fallback BrowseSidebarData shape to include hasPendingVotes. |
| apps/web/app/api/explore/feed/route.ts | Updates degraded/fallback BrowseSidebarData shape to include hasPendingVotes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ); | ||
|
|
||
| const hasPendingVotes = await hasPendingVotesInEditorSpaces(memberSpaceId, editorIds); | ||
|
|
There was a problem hiding this comment.
hasPendingVotesInEditorSpaces could be started earlier/in parallel: it only depends on memberSpaceId and editorIds, but currently runs after fetchSpaceRows and list construction. Kicking it off once editorIds are available (e.g., storing a promise and awaiting it near the return) would reduce sidebar data fetch latency without changing behavior.
| <span>Governance</span> | ||
| <span className="min-w-0 flex-1 text-left">Governance</span> | ||
| {hasPendingVotes ? ( | ||
| <span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-red-01" aria-hidden="true" /> |
There was a problem hiding this comment.
The red-dot notification is currently marked aria-hidden, so assistive tech users won’t be informed that there are pending votes. If this indicator represents actionable state, consider adding an accessible label (e.g., a visually-hidden "Pending votes" text node, or aria-label on a non-hidden element) so the status is conveyed beyond color alone.
| <span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-red-01" aria-hidden="true" /> | |
| <> | |
| <span className="sr-only">Pending votes</span> | |
| <span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-red-01" aria-hidden="true" /> | |
| </> |
|
|
||
| async function hasPendingVotesInEditorSpaces(memberSpaceId: string, editorSpaceIds: string[]): Promise<boolean> { | ||
| if (editorSpaceIds.length === 0) return false; | ||
|
|
||
| const config = Environment.getConfig(); | ||
|
|
||
| for (const spaceId of editorSpaceIds) { | ||
| let cursor: string | null = null; | ||
|
|
||
| for (let page = 0; page < GOVERNANCE_PROPOSAL_MAX_PAGES; page++) { | ||
| const params = new URLSearchParams(); | ||
| params.set('limit', String(GOVERNANCE_PROPOSAL_PAGE_SIZE)); | ||
| params.set('status', 'PROPOSED'); | ||
| params.set('orderBy', 'end_time'); | ||
| params.set('orderDirection', 'desc'); | ||
| params.set('voterId', memberSpaceId); | ||
| if (cursor) { | ||
| params.set('cursor', cursor); | ||
| } | ||
|
|
||
| const path = `/proposals/space/${encodePathSegment(spaceId)}/status?${params.toString()}`; | ||
|
|
||
| const result = await Effect.runPromise( | ||
| Effect.either( | ||
| restFetch<unknown>({ | ||
| endpoint: config.api, | ||
| path, | ||
| }) | ||
| ) | ||
| ); | ||
|
|
||
| if (Either.isLeft(result)) { | ||
| console.error(`Failed to fetch governance proposals for space ${spaceId}:`, result.left); | ||
| break; | ||
| } | ||
|
|
||
| const decoded = Schema.decodeUnknownEither(ApiProposalListResponseSchema)(result.right); | ||
| if (Either.isLeft(decoded)) { | ||
| console.error(`Failed to decode governance proposals for space ${spaceId}:`, decoded.left); | ||
| break; | ||
| } | ||
|
|
||
| if (decoded.right.proposals.some(proposal => proposal.userVote == null)) { | ||
| return true; | ||
| } | ||
|
|
||
| cursor = decoded.right.nextCursor; | ||
| if (!cursor) break; | ||
| } | ||
| } | ||
|
|
||
| return false; |
There was a problem hiding this comment.
hasPendingVotesInEditorSpaces can issue up to editorSpaceIds.length * GOVERNANCE_PROPOSAL_MAX_PAGES sequential REST requests (100 items/page). Since this runs as part of fetchBrowseSidebarData (used by the sidebar on page load), it can add significant latency and backend load for accounts that are editor in many spaces. Consider limiting the work (e.g., smaller page cap/limit, early exit after first page, or parallelizing with a small concurrency limit + request timeout) and/or caching the result server-side.
| async function hasPendingVotesInEditorSpaces(memberSpaceId: string, editorSpaceIds: string[]): Promise<boolean> { | |
| if (editorSpaceIds.length === 0) return false; | |
| const config = Environment.getConfig(); | |
| for (const spaceId of editorSpaceIds) { | |
| let cursor: string | null = null; | |
| for (let page = 0; page < GOVERNANCE_PROPOSAL_MAX_PAGES; page++) { | |
| const params = new URLSearchParams(); | |
| params.set('limit', String(GOVERNANCE_PROPOSAL_PAGE_SIZE)); | |
| params.set('status', 'PROPOSED'); | |
| params.set('orderBy', 'end_time'); | |
| params.set('orderDirection', 'desc'); | |
| params.set('voterId', memberSpaceId); | |
| if (cursor) { | |
| params.set('cursor', cursor); | |
| } | |
| const path = `/proposals/space/${encodePathSegment(spaceId)}/status?${params.toString()}`; | |
| const result = await Effect.runPromise( | |
| Effect.either( | |
| restFetch<unknown>({ | |
| endpoint: config.api, | |
| path, | |
| }) | |
| ) | |
| ); | |
| if (Either.isLeft(result)) { | |
| console.error(`Failed to fetch governance proposals for space ${spaceId}:`, result.left); | |
| break; | |
| } | |
| const decoded = Schema.decodeUnknownEither(ApiProposalListResponseSchema)(result.right); | |
| if (Either.isLeft(decoded)) { | |
| console.error(`Failed to decode governance proposals for space ${spaceId}:`, decoded.left); | |
| break; | |
| } | |
| if (decoded.right.proposals.some(proposal => proposal.userVote == null)) { | |
| return true; | |
| } | |
| cursor = decoded.right.nextCursor; | |
| if (!cursor) break; | |
| } | |
| } | |
| return false; | |
| const GOVERNANCE_PROPOSAL_SPACE_CONCURRENCY = 4; | |
| async function hasPendingVotesInEditorSpace( | |
| memberSpaceId: string, | |
| spaceId: string, | |
| config: ReturnType<typeof Environment.getConfig> | |
| ): Promise<boolean> { | |
| let cursor: string | null = null; | |
| for (let page = 0; page < GOVERNANCE_PROPOSAL_MAX_PAGES; page++) { | |
| const params = new URLSearchParams(); | |
| params.set('limit', String(GOVERNANCE_PROPOSAL_PAGE_SIZE)); | |
| params.set('status', 'PROPOSED'); | |
| params.set('orderBy', 'end_time'); | |
| params.set('orderDirection', 'desc'); | |
| params.set('voterId', memberSpaceId); | |
| if (cursor) { | |
| params.set('cursor', cursor); | |
| } | |
| const path = `/proposals/space/${encodePathSegment(spaceId)}/status?${params.toString()}`; | |
| const result = await Effect.runPromise( | |
| Effect.either( | |
| restFetch<unknown>({ | |
| endpoint: config.api, | |
| path, | |
| }) | |
| ) | |
| ); | |
| if (Either.isLeft(result)) { | |
| console.error(`Failed to fetch governance proposals for space ${spaceId}:`, result.left); | |
| return false; | |
| } | |
| const decoded = Schema.decodeUnknownEither(ApiProposalListResponseSchema)(result.right); | |
| if (Either.isLeft(decoded)) { | |
| console.error(`Failed to decode governance proposals for space ${spaceId}:`, decoded.left); | |
| return false; | |
| } | |
| if (decoded.right.proposals.some(proposal => proposal.userVote == null)) { | |
| return true; | |
| } | |
| cursor = decoded.right.nextCursor; | |
| if (!cursor) break; | |
| } | |
| return false; | |
| } | |
| async function hasPendingVotesInEditorSpaces(memberSpaceId: string, editorSpaceIds: string[]): Promise<boolean> { | |
| if (editorSpaceIds.length === 0) return false; | |
| const config = Environment.getConfig(); | |
| const concurrency = Math.min(GOVERNANCE_PROPOSAL_SPACE_CONCURRENCY, editorSpaceIds.length); | |
| let nextIndex = 0; | |
| let foundPendingVote = false; | |
| const worker = async () => { | |
| while (!foundPendingVote) { | |
| const currentIndex = nextIndex; | |
| nextIndex += 1; | |
| if (currentIndex >= editorSpaceIds.length) { | |
| return; | |
| } | |
| const hasPendingVotes = await hasPendingVotesInEditorSpace(memberSpaceId, editorSpaceIds[currentIndex], config); | |
| if (hasPendingVotes) { | |
| foundPendingVote = true; | |
| return; | |
| } | |
| } | |
| }; | |
| await Promise.all(Array.from({ length: concurrency }, () => worker())); | |
| return foundPendingVote; |
| const GOVERNANCE_PROPOSAL_PAGE_SIZE = 100; | ||
| const GOVERNANCE_PROPOSAL_MAX_PAGES = 40; | ||
|
|
||
| async function hasPendingVotesInEditorSpaces(memberSpaceId: string, editorSpaceIds: string[]): Promise<boolean> { | ||
| if (editorSpaceIds.length === 0) return false; | ||
|
|
||
| const config = Environment.getConfig(); | ||
|
|
||
| for (const spaceId of editorSpaceIds) { | ||
| let cursor: string | null = null; | ||
|
|
||
| for (let page = 0; page < GOVERNANCE_PROPOSAL_MAX_PAGES; page++) { | ||
| const params = new URLSearchParams(); | ||
| params.set('limit', String(GOVERNANCE_PROPOSAL_PAGE_SIZE)); | ||
| params.set('status', 'PROPOSED'); | ||
| params.set('orderBy', 'end_time'); | ||
| params.set('orderDirection', 'desc'); | ||
| params.set('voterId', memberSpaceId); | ||
| if (cursor) { | ||
| params.set('cursor', cursor); | ||
| } | ||
|
|
||
| const path = `/proposals/space/${encodePathSegment(spaceId)}/status?${params.toString()}`; | ||
|
|
||
| const result = await Effect.runPromise( | ||
| Effect.either( | ||
| restFetch<unknown>({ | ||
| endpoint: config.api, | ||
| path, | ||
| }) | ||
| ) | ||
| ); | ||
|
|
||
| if (Either.isLeft(result)) { | ||
| console.error(`Failed to fetch governance proposals for space ${spaceId}:`, result.left); | ||
| break; | ||
| } | ||
|
|
||
| const decoded = Schema.decodeUnknownEither(ApiProposalListResponseSchema)(result.right); | ||
| if (Either.isLeft(decoded)) { | ||
| console.error(`Failed to decode governance proposals for space ${spaceId}:`, decoded.left); | ||
| break; | ||
| } | ||
|
|
||
| if (decoded.right.proposals.some(proposal => proposal.userVote == null)) { | ||
| return true; | ||
| } | ||
|
|
||
| cursor = decoded.right.nextCursor; | ||
| if (!cursor) break; |
There was a problem hiding this comment.
This introduces a second copy of the REST pagination constants/logic (GOVERNANCE_PROPOSAL_PAGE_SIZE/GOVERNANCE_PROPOSAL_MAX_PAGES + cursor loop). There is already similar paging code in core/io/fetch-sidebar-counts.ts (REST_PAGE_SIZE/MAX_PAGES_PER_SPACE + fetchProposalPagesForSpace). Consider extracting a shared helper or reusing the existing one to avoid divergence if the API contract or paging defaults change.
| const GOVERNANCE_PROPOSAL_PAGE_SIZE = 100; | |
| const GOVERNANCE_PROPOSAL_MAX_PAGES = 40; | |
| async function hasPendingVotesInEditorSpaces(memberSpaceId: string, editorSpaceIds: string[]): Promise<boolean> { | |
| if (editorSpaceIds.length === 0) return false; | |
| const config = Environment.getConfig(); | |
| for (const spaceId of editorSpaceIds) { | |
| let cursor: string | null = null; | |
| for (let page = 0; page < GOVERNANCE_PROPOSAL_MAX_PAGES; page++) { | |
| const params = new URLSearchParams(); | |
| params.set('limit', String(GOVERNANCE_PROPOSAL_PAGE_SIZE)); | |
| params.set('status', 'PROPOSED'); | |
| params.set('orderBy', 'end_time'); | |
| params.set('orderDirection', 'desc'); | |
| params.set('voterId', memberSpaceId); | |
| if (cursor) { | |
| params.set('cursor', cursor); | |
| } | |
| const path = `/proposals/space/${encodePathSegment(spaceId)}/status?${params.toString()}`; | |
| const result = await Effect.runPromise( | |
| Effect.either( | |
| restFetch<unknown>({ | |
| endpoint: config.api, | |
| path, | |
| }) | |
| ) | |
| ); | |
| if (Either.isLeft(result)) { | |
| console.error(`Failed to fetch governance proposals for space ${spaceId}:`, result.left); | |
| break; | |
| } | |
| const decoded = Schema.decodeUnknownEither(ApiProposalListResponseSchema)(result.right); | |
| if (Either.isLeft(decoded)) { | |
| console.error(`Failed to decode governance proposals for space ${spaceId}:`, decoded.left); | |
| break; | |
| } | |
| if (decoded.right.proposals.some(proposal => proposal.userVote == null)) { | |
| return true; | |
| } | |
| cursor = decoded.right.nextCursor; | |
| if (!cursor) break; | |
| const GOVERNANCE_PROPOSAL_PAGINATION = { | |
| pageSize: 100, | |
| maxPages: 40, | |
| } as const; | |
| async function fetchGovernanceProposalPagesForSpace(memberSpaceId: string, spaceId: string) { | |
| const config = Environment.getConfig(); | |
| const pages: Array<Schema.Schema.Type<typeof ApiProposalListResponseSchema>> = []; | |
| let cursor: string | null = null; | |
| for (let page = 0; page < GOVERNANCE_PROPOSAL_PAGINATION.maxPages; page++) { | |
| const params = new URLSearchParams(); | |
| params.set('limit', String(GOVERNANCE_PROPOSAL_PAGINATION.pageSize)); | |
| params.set('status', 'PROPOSED'); | |
| params.set('orderBy', 'end_time'); | |
| params.set('orderDirection', 'desc'); | |
| params.set('voterId', memberSpaceId); | |
| if (cursor) { | |
| params.set('cursor', cursor); | |
| } | |
| const path = `/proposals/space/${encodePathSegment(spaceId)}/status?${params.toString()}`; | |
| const result = await Effect.runPromise( | |
| Effect.either( | |
| restFetch<unknown>({ | |
| endpoint: config.api, | |
| path, | |
| }) | |
| ) | |
| ); | |
| if (Either.isLeft(result)) { | |
| console.error(`Failed to fetch governance proposals for space ${spaceId}:`, result.left); | |
| break; | |
| } | |
| const decoded = Schema.decodeUnknownEither(ApiProposalListResponseSchema)(result.right); | |
| if (Either.isLeft(decoded)) { | |
| console.error(`Failed to decode governance proposals for space ${spaceId}:`, decoded.left); | |
| break; | |
| } | |
| pages.push(decoded.right); | |
| cursor = decoded.right.nextCursor; | |
| if (!cursor) break; | |
| } | |
| return pages; | |
| } | |
| async function hasPendingVotesInEditorSpaces(memberSpaceId: string, editorSpaceIds: string[]): Promise<boolean> { | |
| if (editorSpaceIds.length === 0) return false; | |
| for (const spaceId of editorSpaceIds) { | |
| const proposalPages = await fetchGovernanceProposalPagesForSpace(memberSpaceId, spaceId); | |
| if (proposalPages.some(page => page.proposals.some(proposal => proposal.userVote == null))) { | |
| return true; |
7fb302e to
0cbbf75
Compare
Address Copilot review feedback on the governance red-dot indicator: - Fan out pending-votes lookups across editor spaces (concurrency=4) with cooperative early-exit, instead of serial per-space paging. Reduces sidebar data-fetch latency for editor-heavy accounts. - Add a visually-hidden 'Pending votes' label alongside the aria-hidden dot so screen-reader users get the same signal as sighted users.
After a vote transaction submits successfully, the list used to wait for router.refresh() plus backend indexing before the voted proposal sank to the bottom of its bucket — a visible multi-second delay. Track optimistically-voted proposal ids in a jotai atom, written from the AcceptOrReject success callback. Each list item is wrapped in a client component that applies a CSS 'order' bump (+5000) within its bucket when optimistically voted. Bucket base offsets (0/10000/20000) guarantee the bump cannot overshoot into the next bucket. When router.refresh() eventually returns fresh data with userVote set, the server's own sort already places the item in the same spot, so the optimistic state and server truth agree with no visual jitter.
Previously the optimistic vote fired in the mutation's onSuccess, which still waits for the wallet transaction to submit. Fire it at click time instead so the card sinks to the bottom immediately, and roll back in onError if the transaction fails.
The red dot's server-computed hasPendingVotes boolean waited on backend indexing, so it stayed visible after the user voted on the last pending proposal until a full page refresh. Return the list of pending proposal ids from the sidebar data fetch instead of a boolean, and compute the dot's visibility client-side by filtering out ids already present in the optimistic-vote atom. Once the user votes on every pending proposal the dot disappears instantly; if a future server refresh brings new pending ids, the dot reappears.
The GovernanceFilterMenu trigger showed the URL-derived label, so after clicking an item the trigger kept the old value for 3-4s while the server refetched. Track a local pendingLabel, override the trigger immediately on click, and clear it once the server-derived label catches up. Also switch secondary/ghost/transparent/primary button focus styles from focus: to focus-visible: so the thick inset border only appears on keyboard focus — a mouse click on a dropdown trigger no longer paints a lingering bold outline.
Three synthesized BrowseSidebarData fallbacks in explore and activity routes still referenced the old hasPendingVotes boolean; rename them to pendingVoteProposalIds to match the updated type.
Dropdown changes on /home kick off a full server fetch for the proposal list and sidebar counts, which can take a couple of seconds. The previous UI kept the old content on screen during the fetch, making selections feel unresponsive. Wrap dropdown navigation in useTransition and swap content to skeletons when isPending stays true beyond 150ms. Cached/fast responses resolve before the delay fires, so there is no skeleton flash when data is already warm.
…onents Three follow-up fixes on the governance home loading behavior: - Drop the Suspense key that forced a remount on every filter change. Without it, useTransition can keep the old proposal cards visible during navigation and only the useTransition-driven skeleton shows after the 150ms delay — no more double-skeleton flash between the Suspense fallback and the transition skeleton. - Align the transition skeleton's layout with the real cards so it doesn't visually jump when content swaps in. - Always render the right-rail Sidebar. Its counts don't change with filter selections, so it shouldn't flicker to a skeleton. - Wire AcceptOrRejectEditor and AcceptOrRejectMember into the same optimistic-vote atom as the space-level AcceptOrReject. Voting from the governance home review list now clears the red dot as soon as the last pending proposal is approved/rejected, with error rollback on failed transactions.
Four related fixes for governance loading + vote feedback: - Drop the custom transition-driven skeleton and its 150ms delay. The double-shade flicker was the custom skeleton (bg-grey-01) briefly replacing the real Suspense fallback (bg-grey-02). With useTransition doing the right thing, the Suspense fallback only fires on an actual suspend — instant/prefetched navigations show no skeleton at all. - Split the optimistic-vote atom into two: optimistic (click-driven, drives list reorder) and confirmed (mutation-success-driven, drives sidebar dot). Clicking 'Reject' no longer clears the dot prematurely; the dot clears the moment 'You rejected' appears. - Wire useConfirmVote into all three AcceptOrReject components on vote success. - Poll the browse sidebar data every 30s and refetch on window focus so a newly published proposal starts showing the red dot without a hard refresh.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| {address ? ( | ||
| <Link href={NavUtils.toHome()} className={`${navLinkClass} w-full min-w-0`}> | ||
| <BrowseNavIcon src={BROWSE_NAV_ICON.governance} /> | ||
| <span className="min-w-0 flex-1 text-left">Governance</span> | ||
| {hasPendingVotes ? ( | ||
| <> | ||
| <span className="sr-only">Pending votes</span> | ||
| <span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-red-01" aria-hidden="true" /> | ||
| </> | ||
| ) : null} | ||
| </Link> | ||
| ) : null} |
There was a problem hiding this comment.
The Governance nav link is now only rendered when address is present. This removes the Governance entry entirely for signed-out users, which is a behavior change unrelated to the red-dot and may make the home/governance area unreachable from the sidebar. If Governance should remain accessible while signed out, render the link unconditionally and only conditionally render the pending-vote indicator/dot.
| {address ? ( | |
| <Link href={NavUtils.toHome()} className={`${navLinkClass} w-full min-w-0`}> | |
| <BrowseNavIcon src={BROWSE_NAV_ICON.governance} /> | |
| <span className="min-w-0 flex-1 text-left">Governance</span> | |
| {hasPendingVotes ? ( | |
| <> | |
| <span className="sr-only">Pending votes</span> | |
| <span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-red-01" aria-hidden="true" /> | |
| </> | |
| ) : null} | |
| </Link> | |
| ) : null} | |
| <Link href={NavUtils.toHome()} className={`${navLinkClass} w-full min-w-0`}> | |
| <BrowseNavIcon src={BROWSE_NAV_ICON.governance} /> | |
| <span className="min-w-0 flex-1 text-left">Governance</span> | |
| {address && hasPendingVotes ? ( | |
| <> | |
| <span className="sr-only">Pending votes</span> | |
| <span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-red-01" aria-hidden="true" /> | |
| </> | |
| ) : null} | |
| </Link> |
| React.useEffect(() => { | ||
| let cancelled = false; | ||
| void loadBrowseSidebarData(walletAddress).then(d => { | ||
| if (!cancelled) setData(d); | ||
| }); | ||
| const load = () => { | ||
| void loadBrowseSidebarData(walletAddress).then(d => { | ||
| if (!cancelled) setData(d); | ||
| }); | ||
| }; | ||
| load(); | ||
| const interval = setInterval(load, 30_000); | ||
| const onFocus = () => load(); | ||
| window.addEventListener('focus', onFocus); |
There was a problem hiding this comment.
This adds a 30s polling interval plus a focus listener that re-runs loadBrowseSidebarData. That server action now performs multiple network calls (GraphQL + REST pagination across editor spaces), so this can substantially increase backend/API load per client session. Consider reducing frequency, polling only when the sidebar is open/visible, or switching to a cheaper endpoint (e.g., a single boolean/count for pending votes) to avoid repeated heavy fetches.
| const GOVERNANCE_PROPOSAL_PAGE_SIZE = 100; | ||
| const GOVERNANCE_PROPOSAL_MAX_PAGES = 40; | ||
| const GOVERNANCE_PROPOSAL_SPACE_CONCURRENCY = 4; | ||
|
|
||
| async function collectPendingVoteIdsInSpace( | ||
| memberSpaceId: string, | ||
| spaceId: string, | ||
| apiEndpoint: string | ||
| ): Promise<string[]> { | ||
| const ids: string[] = []; | ||
| let cursor: string | null = null; | ||
|
|
||
| for (let page = 0; page < GOVERNANCE_PROPOSAL_MAX_PAGES; page++) { | ||
| const params = new URLSearchParams(); | ||
| params.set('limit', String(GOVERNANCE_PROPOSAL_PAGE_SIZE)); | ||
| params.set('status', 'PROPOSED'); | ||
| params.set('orderBy', 'end_time'); | ||
| params.set('orderDirection', 'desc'); | ||
| params.set('voterId', memberSpaceId); |
There was a problem hiding this comment.
collectPendingVoteIdsInSpace can fetch up to 40 pages of 100 proposals per space, and collectPendingVoteIdsInEditorSpaces does this across all editor spaces. Combined with client-side polling, this can create a large number of REST calls and potentially large response payloads. Since the sidebar currently only needs to know whether any pending vote exists (or a small bounded set), consider short-circuiting after finding the first unvoted proposal, lowering GOVERNANCE_PROPOSAL_MAX_PAGES, and/or deduping and capping the collected IDs to keep server and API load bounded.
| /** Proposal ids whose card should already appear as "voted" (sunk to bottom of list). Written on click. */ | ||
| const optimisticVotedIdsAtom = atom<Set<string>>(new Set<string>()); | ||
|
|
||
| /** Proposal ids whose vote mutation resolved successfully. Written when useVote reports success. */ | ||
| const confirmedVotedIdsAtom = atom<Set<string>>(new Set<string>()); | ||
|
|
There was a problem hiding this comment.
optimisticVotedIdsAtom / confirmedVotedIdsAtom are global for the whole app session and aren’t scoped to the currently connected wallet / personal space. If a user disconnects and connects a different account without a full reload, these sets can leak state across accounts and incorrectly suppress the pending-vote dot or reorder proposal lists. Consider keying these sets by account (e.g., atom family keyed by address/spaceId) or clearing them on connection change/logout.
| const OPTIMISTIC_VOTE_ORDER_BUMP = 5000; | ||
|
|
||
| export function ProposalListItem({ proposalId, baseOrder, canSink, children }: Props) { | ||
| const isOptimistic = useIsOptimisticallyVoted(proposalId); | ||
| const order = isOptimistic && canSink ? baseOrder + OPTIMISTIC_VOTE_ORDER_BUMP : baseOrder; | ||
| return <div style={{ order }}>{children}</div>; |
There was a problem hiding this comment.
This uses CSS flex order to change the visual ordering of proposals without changing their DOM order. That can create an accessibility mismatch (screen readers and keyboard users may encounter items in a different order than what’s displayed). If the order is meaningful (unvoted first), consider re-sorting the rendered children (or the proposals array) in React based on optimistic state rather than relying on CSS order.
Replace the per-tab full sidebar rescan with a Redis-backed cache kept
in sync by gaia notification-service webhooks:
- POST /api/geo-notifications: receives per-editor fan-out webhooks,
verifies HMAC-SHA256 on raw body bytes, dedupes by idempotency_key,
and mutates a per-user pending-vote set in Upstash:
proposal_created -> SADD
proposal_voted (self) -> SREM
proposal_executed|rejected -> SREM
Bounty/unhandled events are 2xxed so the delivery worker stops
retrying. Redis failures return 503 so the worker retries.
- GET /api/pending-votes: resolves the caller's user_space_id from
the WALLET_ADDRESS cookie (cached in Redis for 24h) and returns
{proposalIds}. Redis miss falls back to the authoritative REST
scan and seeds the cache.
- Sidebar: initial full load unchanged. Pending-vote polling split
into its own effect, bumped to 60s, and routed to /api/pending-votes
when NEXT_PUBLIC_USE_NOTIFICATION_SERVICE=true. Poll is skipped when
the user is not an editor of any space or the tab is hidden.
Per-poll cost drops from ~5+ GraphQL + N-space REST scan to one Redis
SMEMBERS. Self-heals on the 24h TTL if a webhook is ever missed.
Requires gaia to register the webhook URL + shared secret in its
app_webhooks table before the flag is flipped on.
|
Closing as part of a sweep of the open-PR queue. Not a judgement on the work — reopen if you still want it and I will help get it current. Opened 2026-04-20 and now conflicting with master. At this distance a rebase is usually more work than redoing the change against current code, and the surrounding code has moved a long way underneath "fix: red dot next to governance tab". @webmagic123 — if the idea still stands but the branch does not, a fresh PR or a ticket is probably a better route than reviving this one. Nothing is discarded: the branch and its history remain, and reopening costs a click. Context: 74 PRs were open, 25 older than two months, the oldest from February. The point is to make the queue mean something so genuinely ready work is visible rather than buried — #2449 sat ready for three days this week partly because of the noise. Only non-draft, conflicting PRs are in scope; drafts and anything still mergeable are being left alone. |
No description provided.