diff --git a/apps/web/.env.example b/apps/web/.env.example index e52437ecc9..499b0c17a5 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -27,6 +27,14 @@ NEXT_PUBLIC_ONBOARD_CODE= # Features (optional) # NEXT_PUBLIC_MAPBOX_TOKEN= +# Governance red-dot via gaia notification-service (optional) +# When enabled, the browse sidebar polls /api/pending-votes (Redis-backed, driven +# by webhooks from gaia) instead of rescanning all editor spaces each tick. +# Requires UPSTASH_REDIS_REST_URL/TOKEN to be set and the webhook URL registered +# in gaia's app_webhooks table with the matching secret. +# NEXT_PUBLIC_USE_NOTIFICATION_SERVICE=true +# GEO_NOTIFICATIONS_WEBHOOK_SECRET= # HMAC-SHA256 shared secret; generate with `openssl rand -hex 32` + # Build (optional) # DISABLE_REACT_COMPILER=1 # DISABLE_SENTRY=1 diff --git a/apps/web/app/api/activity/feed/route.ts b/apps/web/app/api/activity/feed/route.ts index b099aa1360..518e04385c 100644 --- a/apps/web/app/api/activity/feed/route.ts +++ b/apps/web/app/api/activity/feed/route.ts @@ -53,6 +53,7 @@ export async function GET(request: Request) { editorOf: [], memberOf: [], documentationImage: null, + pendingVoteProposalIds: [], personalSpaceId: null, }; diff --git a/apps/web/app/api/explore/feed/route.ts b/apps/web/app/api/explore/feed/route.ts index e25142b797..99fe1b37b8 100644 --- a/apps/web/app/api/explore/feed/route.ts +++ b/apps/web/app/api/explore/feed/route.ts @@ -68,6 +68,7 @@ export async function GET(request: Request) { editorOf: [], memberOf: [], documentationImage: null, + pendingVoteProposalIds: [], personalSpaceId: null, }; } diff --git a/apps/web/app/api/geo-notifications/route.ts b/apps/web/app/api/geo-notifications/route.ts new file mode 100644 index 0000000000..9a335bbc3a --- /dev/null +++ b/apps/web/app/api/geo-notifications/route.ts @@ -0,0 +1,97 @@ +import { Either, Schema } from 'effect'; +import { NextResponse } from 'next/server'; + +import { addPendingVote, markNotificationSeen, removePendingVote } from '~/core/notifications/pending-votes-store'; +import { verifyGeoSignature } from '~/core/notifications/verify-signature'; +import { + RedDotWebhookPayloadSchema, + type RedDotWebhookPayload, +} from '~/core/notifications/webhook-payload-schema'; + +/** + * Webhook receiver for the gaia notification-service. + * + * Registration: gaia's `app_webhooks` table holds the URL + shared secret. + * The service fans out one POST per (editor, event); we update a per-user + * pending-vote set in Redis that the sidebar reads via /api/pending-votes. + * + * Responses: + * - 200: accepted (including unhandled event types, which we intentionally + * drop so the delivery worker doesn't retry) + * - 401: missing or bad signature + * - 500: misconfigured secret (never reached in prod if env is set) + */ +export async function POST(request: Request) { + const secret = process.env.GEO_NOTIFICATIONS_WEBHOOK_SECRET; + if (!secret) { + console.error('[geo-notifications] GEO_NOTIFICATIONS_WEBHOOK_SECRET not set'); + return new NextResponse('webhook secret not configured', { status: 500 }); + } + + // Read the raw body once — HMAC must verify the exact bytes sent, not a re-serialized JSON. + const rawBody = await request.text(); + + const valid = verifyGeoSignature({ + rawBody, + signatureHeader: request.headers.get('X-Geo-Signature'), + secret, + }); + if (!valid) { + return new NextResponse('invalid signature', { status: 401 }); + } + + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + // Signature was valid but body isn't JSON. 2xx so it doesn't get retried; log and move on. + console.error('[geo-notifications] signed body was not valid JSON'); + return NextResponse.json({ ok: true }); + } + + const decoded = Schema.decodeUnknownEither(RedDotWebhookPayloadSchema)(parsed); + if (Either.isLeft(decoded)) { + // Unhandled event_type (bounty_*, proposal_updated, proposal_settings_updated, etc.) — + // acknowledge so the service stops retrying. + return NextResponse.json({ ok: true }); + } + + const payload = decoded.right; + const firstTime = await markNotificationSeen(payload.idempotency_key); + if (!firstTime) { + return NextResponse.json({ ok: true, deduped: true }); + } + + try { + await handleEvent(payload); + } catch (error) { + // Redis transient failure: return 5xx so the delivery worker retries per its schedule. + console.error('[geo-notifications] failed to update pending-vote cache', error); + return new NextResponse('transient cache error', { status: 503 }); + } + + return NextResponse.json({ ok: true }); +} + +async function handleEvent(payload: RedDotWebhookPayload) { + switch (payload.event_type) { + case 'proposal_created': + await addPendingVote(payload.user_space_id, payload.proposal_id); + return; + + case 'proposal_voted': + // The service fans out proposal_voted to every editor in the space. For the red + // dot we only care when the recipient *is* the voter — then this user's pending + // item clears. All other recipients' pending state is unchanged by this vote. + if (payload.voter_id && payload.voter_id === payload.user_space_id) { + await removePendingVote(payload.user_space_id, payload.proposal_id); + } + return; + + case 'proposal_executed': + case 'proposal_rejected': + // Proposal is closed → nobody needs to vote on it anymore. + await removePendingVote(payload.user_space_id, payload.proposal_id); + return; + } +} diff --git a/apps/web/app/api/pending-votes/route.ts b/apps/web/app/api/pending-votes/route.ts new file mode 100644 index 0000000000..25e2db8575 --- /dev/null +++ b/apps/web/app/api/pending-votes/route.ts @@ -0,0 +1,63 @@ +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; + +import { fetchPendingVoteProposalIds } from '~/core/browse/fetch-browse-sidebar-data'; +import { resolveMemberSpaceFromWalletSafe } from '~/core/browse/resolve-member-space-from-wallet'; +import { WALLET_ADDRESS } from '~/core/cookie'; +import { fetchEditorSpaceIds } from '~/core/io/subgraph/fetch-editor-space-ids'; +import { + getCachedWalletSpace, + getPendingVotes, + seedPendingVotes, + setCachedWalletSpace, +} from '~/core/notifications/pending-votes-store'; + +/** + * Lightweight endpoint the browse sidebar polls for the red-dot state. + * + * Fast path: Redis SMEMBERS on pending_votes:{user_space_id}. Single-digit ms. + * + * Fallback: if the cache has no entry (cold start or TTL'd out), run the + * authoritative REST scan once and seed the cache. Subsequent polls hit + * the fast path until webhooks or TTL expire the key again. + * + * Never accepts a userSpaceId from the caller — the caller can only see + * their own state, resolved from the WALLET_ADDRESS cookie. + */ +export async function GET() { + const walletAddress = (await cookies()).get(WALLET_ADDRESS)?.value; + if (!walletAddress) { + return NextResponse.json({ proposalIds: [] }); + } + + const userSpaceId = await resolveUserSpaceId(walletAddress); + if (!userSpaceId) { + return NextResponse.json({ proposalIds: [] }); + } + + const cached = await getPendingVotes(userSpaceId); + if (cached.hit) { + return NextResponse.json({ proposalIds: cached.proposalIds }); + } + + // Cold cache: rebuild from authoritative source, then seed Redis so subsequent polls hit fast path. + const editorSpaceIds = await fetchEditorSpaceIds(userSpaceId); + if (editorSpaceIds.length === 0) { + await seedPendingVotes(userSpaceId, []); + return NextResponse.json({ proposalIds: [] }); + } + + const proposalIds = await fetchPendingVoteProposalIds(userSpaceId, editorSpaceIds); + await seedPendingVotes(userSpaceId, proposalIds); + return NextResponse.json({ proposalIds }); +} + +async function resolveUserSpaceId(walletAddress: string): Promise { + const cached = await getCachedWalletSpace(walletAddress); + if (cached !== null) { + return cached === '' ? null : cached; + } + const resolved = await resolveMemberSpaceFromWalletSafe(walletAddress); + await setCachedWalletSpace(walletAddress, resolved); + return resolved; +} diff --git a/apps/web/app/explore/page.tsx b/apps/web/app/explore/page.tsx index 59c761f8a2..0423be55ba 100644 --- a/apps/web/app/explore/page.tsx +++ b/apps/web/app/explore/page.tsx @@ -27,6 +27,7 @@ export default async function ExploreRoutePage() { editorOf: [], memberOf: [], documentationImage: null, + pendingVoteProposalIds: [], personalSpaceId: null, }; } diff --git a/apps/web/app/home/accept-or-reject-editor.tsx b/apps/web/app/home/accept-or-reject-editor.tsx index a8d491cadb..2442c00b57 100644 --- a/apps/web/app/home/accept-or-reject-editor.tsx +++ b/apps/web/app/home/accept-or-reject-editor.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import { useRouter } from 'next/navigation'; import { useSmartAccount } from '~/core/hooks/use-smart-account'; import { useVote } from '~/core/hooks/use-vote'; @@ -11,6 +12,7 @@ import { SmallButton } from '~/design-system/button'; import { Pending } from '~/design-system/pending'; import { Execute } from '~/partials/active-proposal/execute'; +import { useAddOptimisticVote, useConfirmVote, useRemoveOptimisticVote } from '~/partials/governance/optimistic-voted-atom'; interface Props { spaceId: string; @@ -30,6 +32,8 @@ export function AcceptOrRejectEditor({ userVote, proposalId, }: Props) { + const router = useRouter(); + const { vote, status: voteStatus } = useVote({ spaceId, proposalId, @@ -43,15 +47,29 @@ export function AcceptOrRejectEditor({ const isPendingRejection = hasRejected && voteStatus === 'pending'; const { smartAccount } = useSmartAccount(); + const addOptimisticVote = useAddOptimisticVote(); + const removeOptimisticVote = useRemoveOptimisticVote(); + const confirmVote = useConfirmVote(); + + const onVoteSuccess = () => { + confirmVote(proposalId); + router.refresh(); + }; + + const onVoteError = () => { + removeOptimisticVote(proposalId); + }; const onApprove = () => { setHasApproved(true); - vote('ACCEPT'); + addOptimisticVote(proposalId); + vote('ACCEPT', { onSuccess: onVoteSuccess, onError: onVoteError }); }; const onReject = () => { setHasRejected(true); - vote('REJECT'); + addOptimisticVote(proposalId); + vote('REJECT', { onSuccess: onVoteSuccess, onError: onVoteError }); }; // Terminal / post-vote states on the proposal must win over "You accepted" so we match space diff --git a/apps/web/app/home/accept-or-reject-member.tsx b/apps/web/app/home/accept-or-reject-member.tsx index 4597b2f4fb..110f4dadc0 100644 --- a/apps/web/app/home/accept-or-reject-member.tsx +++ b/apps/web/app/home/accept-or-reject-member.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, type ReactNode } from 'react'; +import { useRouter } from 'next/navigation'; import cx from 'classnames'; @@ -23,6 +24,7 @@ import { Pending } from '~/design-system/pending'; import { PrefetchLink as Link } from '~/design-system/prefetch-link'; import { Execute } from '~/partials/active-proposal/execute'; +import { useAddOptimisticVote, useConfirmVote, useRemoveOptimisticVote } from '~/partials/governance/optimistic-voted-atom'; interface Props { spaceId: string; @@ -65,6 +67,8 @@ export function AcceptOrRejectMember({ }: Props) { const [selectedVote, setSelectedVote] = useState<'ACCEPT' | 'REJECT' | null>(null); + const router = useRouter(); + const { vote, status: voteStatus } = useVote({ spaceId, proposalId, @@ -76,15 +80,29 @@ export function AcceptOrRejectMember({ const isPendingRejection = selectedVote === 'REJECT' && voteStatus === 'pending'; const { smartAccount } = useSmartAccount(); + const addOptimisticVote = useAddOptimisticVote(); + const removeOptimisticVote = useRemoveOptimisticVote(); + const confirmVote = useConfirmVote(); + + const onVoteSuccess = () => { + confirmVote(proposalId); + router.refresh(); + }; + + const onVoteError = () => { + removeOptimisticVote(proposalId); + }; const onApprove = () => { setSelectedVote('ACCEPT'); - vote('ACCEPT'); + addOptimisticVote(proposalId); + vote('ACCEPT', { onSuccess: onVoteSuccess, onError: onVoteError }); }; const onReject = () => { setSelectedVote('REJECT'); - vote('REJECT'); + addOptimisticVote(proposalId); + vote('REJECT', { onSuccess: onVoteSuccess, onError: onVoteError }); }; const { hours, minutes } = getProposalTimeRemaining(endTime); diff --git a/apps/web/app/home/component.tsx b/apps/web/app/home/component.tsx index b3baa8feaa..78da9ca6a7 100644 --- a/apps/web/app/home/component.tsx +++ b/apps/web/app/home/component.tsx @@ -44,8 +44,6 @@ export async function Component({ myProposalSpaceOptions, myProposalSpaceIds, }: Props) { - const listKey = `${governanceTab}-${governanceFilters.spaceId}-${governanceFilters.category}-${governanceFilters.status}-${proposalType}-${connectedAddress}`; - return ( <>
@@ -60,7 +58,6 @@ export async function Component({

Sign in to see your proposals.

) : governanceTab === 'my' && connectedSpaceId ? ( @@ -81,7 +78,6 @@ export async function Component({ ) : ( @@ -135,7 +131,7 @@ async function PendingProposals({ connectedSpaceId, governanceFilters, }: PendingProposalsProps) { - const { node, hasMore } = await PendingProposalsPage({ + const { node, hasMore, page0OrderKey } = await PendingProposalsPage({ connectedSpaceId, connectedAddress, proposalType, @@ -152,6 +148,7 @@ async function PendingProposals({ {node} {hasMore && connectedSpaceId && ( p.status === 'PROPOSED') : allRows; - votingRows.sort((a, b) => b.timing.endTime - a.timing.endTime); const unique = dedupeByProposalId(votingRows); + if (status === 'pending') { + unique.sort((a, b) => { + const aVoted = a.userVote != null; + const bVoted = b.userVote != null; + if (aVoted !== bVoted) return aVoted ? 1 : -1; + return b.timing.endTime - a.timing.endTime; + }); + } else { + unique.sort((a, b) => b.timing.endTime - a.timing.endTime); + } const offset = page * PAGE_SIZE; const pageSlice = unique.slice(offset, offset + PAGE_SIZE); diff --git a/apps/web/app/home/pending-proposals-page.tsx b/apps/web/app/home/pending-proposals-page.tsx index f084aba14a..2b325ac937 100644 --- a/apps/web/app/home/pending-proposals-page.tsx +++ b/apps/web/app/home/pending-proposals-page.tsx @@ -52,14 +52,16 @@ export async function PendingProposalsPage({ proposalType, page = 0, governanceFilters, -}: Props): Promise<{ node: React.ReactNode; hasMore: boolean }> { +}: Props): Promise<{ node: React.ReactNode; hasMore: boolean; page0OrderKey: string }> { const [activeProposals, profile] = await Promise.all([ getActiveProposalsForSpacesWhereEditor(connectedSpaceId, proposalType, page, governanceFilters), connectedAddress ? Effect.runPromise(fetchProfile(connectedAddress)) : null, ]); + const page0OrderKey = activeProposals.proposals.map(p => p.id).join('|'); + if (activeProposals.proposals.length === 0) { - return { node: null, hasMore: false }; + return { node: null, hasMore: false, page0OrderKey: '' }; } const user = @@ -82,6 +84,7 @@ export async function PendingProposalsPage({ : undefined; return { + page0OrderKey, node: (
{activeProposals.proposals.map(proposal => { diff --git a/apps/web/app/home/personal-home-dashboard.tsx b/apps/web/app/home/personal-home-dashboard.tsx index 37a21ffc34..8ec00330be 100644 --- a/apps/web/app/home/personal-home-dashboard.tsx +++ b/apps/web/app/home/personal-home-dashboard.tsx @@ -227,12 +227,24 @@ function GovernanceFilterMenu({ maxHeightClass?: string; }) { const [open, setOpen] = React.useState(false); + const [pendingLabel, setPendingLabel] = React.useState(null); + const prevLabelRef = React.useRef(label); + + React.useEffect(() => { + if (prevLabelRef.current !== label) { + prevLabelRef.current = label; + setPendingLabel(null); + } + }, [label]); + + const displayLabel = pendingLabel ?? label; + return ( }>{label}} + trigger={}>{displayLabel}} align="start" >
@@ -240,7 +252,10 @@ function GovernanceFilterMenu({ setOpen(false)} + onClick={() => { + if (item.label !== label) setPendingLabel(item.label); + setOpen(false); + }} className="flex w-full cursor-pointer items-center gap-2 bg-white px-3 py-2.5 hover:bg-bg" > {showImages && item.showImage !== false ? ( diff --git a/apps/web/core/browse/fetch-browse-sidebar-data.ts b/apps/web/core/browse/fetch-browse-sidebar-data.ts index 2043111c52..6d626629b9 100644 --- a/apps/web/core/browse/fetch-browse-sidebar-data.ts +++ b/apps/web/core/browse/fetch-browse-sidebar-data.ts @@ -1,8 +1,10 @@ import * as Effect from 'effect/Effect'; import * as Either from 'effect/Either'; +import { Schema } from 'effect'; import { DOCUMENTATION_SPACE_ID, PLACEHOLDER_SPACE_IMAGE } from '~/core/constants'; import { Environment } from '~/core/environment'; +import { ApiProposalListResponseSchema, encodePathSegment, restFetch } from '~/core/io/rest'; import { getSpaces, getSpacesWhereMember } from '~/core/io/queries'; import { graphql } from '~/core/io/subgraph/graphql'; @@ -25,10 +27,103 @@ export type BrowseSidebarData = { editorOf: BrowseSpaceRow[]; memberOf: BrowseSpaceRow[]; documentationImage: string | null; + /** Proposal ids the user can still vote on across their editor spaces. Empty when nothing is pending. */ + pendingVoteProposalIds: string[]; /** Personal space id used for membership/editor GraphQL (same as browse “member space”). */ personalSpaceId: string | null; }; +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 { + 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); + if (cursor) { + params.set('cursor', cursor); + } + + const path = `/proposals/space/${encodePathSegment(spaceId)}/status?${params.toString()}`; + + const result = await Effect.runPromise( + Effect.either( + restFetch({ + endpoint: apiEndpoint, + path, + }) + ) + ); + + if (Either.isLeft(result)) { + console.error(`Failed to fetch governance proposals for space ${spaceId}:`, result.left); + return ids; + } + + const decoded = Schema.decodeUnknownEither(ApiProposalListResponseSchema)(result.right); + if (Either.isLeft(decoded)) { + console.error(`Failed to decode governance proposals for space ${spaceId}:`, decoded.left); + return ids; + } + + for (const proposal of decoded.right.proposals) { + if (proposal.userVote == null) { + ids.push(proposal.proposalId); + } + } + + cursor = decoded.right.nextCursor; + if (!cursor) break; + } + + return ids; +} + +export async function fetchPendingVoteProposalIds( + memberSpaceId: string, + editorSpaceIds: string[] +): Promise { + return collectPendingVoteIdsInEditorSpaces(memberSpaceId, editorSpaceIds); +} + +async function collectPendingVoteIdsInEditorSpaces( + memberSpaceId: string, + editorSpaceIds: string[] +): Promise { + if (editorSpaceIds.length === 0) return []; + + const apiEndpoint = Environment.getConfig().api; + const concurrency = Math.min(GOVERNANCE_PROPOSAL_SPACE_CONCURRENCY, editorSpaceIds.length); + let nextIndex = 0; + const collected: string[] = []; + + const worker = async () => { + while (true) { + const currentIndex = nextIndex++; + if (currentIndex >= editorSpaceIds.length) return; + + const ids = await collectPendingVoteIdsInSpace(memberSpaceId, editorSpaceIds[currentIndex], apiEndpoint); + if (ids.length > 0) collected.push(...ids); + } + }; + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + + return collected; +} + async function fetchSpaceRows(ids: string[]): Promise> { const unique = [...new Set(ids.filter(Boolean))]; if (unique.length === 0) return new Map(); @@ -139,6 +234,7 @@ export async function fetchBrowseSidebarData(memberSpaceId: string | null | unde editorOf: [], memberOf: [], documentationImage: featuredOnly.get(DOCUMENTATION_SPACE_ID)?.image ?? null, + pendingVoteProposalIds: [], personalSpaceId: null, }; } @@ -158,6 +254,8 @@ export async function fetchBrowseSidebarData(memberSpaceId: string | null | unde ), ]); + const pendingVoteIdsPromise = collectPendingVoteIdsInEditorSpaces(memberSpaceId, editorIds); + const editorIdSet = new Set(editorIds); const pendingMemberIds = new Set(); const pendingEditorIds = new Set(); @@ -227,11 +325,14 @@ export async function fetchBrowseSidebarData(memberSpaceId: string | null | unde }) ); + const pendingVoteProposalIds = await pendingVoteIdsPromise; + return { featured, editorOf, memberOf, documentationImage: rows.get(DOCUMENTATION_SPACE_ID)?.image ?? null, + pendingVoteProposalIds, personalSpaceId: memberSpaceId, }; } diff --git a/apps/web/core/notifications/pending-votes-store.ts b/apps/web/core/notifications/pending-votes-store.ts new file mode 100644 index 0000000000..c8ce0ce3fb --- /dev/null +++ b/apps/web/core/notifications/pending-votes-store.ts @@ -0,0 +1,79 @@ +import { Redis } from '@upstash/redis'; + +const redis = Redis.fromEnv(); + +const PENDING_VOTES_TTL_SECONDS = 24 * 60 * 60; // 24h — self-heal if a webhook is missed +const NOTIF_SEEN_TTL_SECONDS = 7 * 24 * 60 * 60; // 7d — idempotency deduplication window +const WALLET_SPACE_TTL_SECONDS = 24 * 60 * 60; // 24h — maps wallet → user_space_id to avoid a subgraph hit per poll + +const pendingVotesKey = (userSpaceId: string) => `pending_votes:${userSpaceId}`; +const notifSeenKey = (idempotencyKey: string) => `notif_seen:${idempotencyKey}`; +const walletSpaceKey = (walletAddress: string) => `wallet_space:${walletAddress.toLowerCase()}`; + +/** Records a webhook as already processed. Returns false if it was already seen. */ +export async function markNotificationSeen(idempotencyKey: string): Promise { + const res = await redis.set(notifSeenKey(idempotencyKey), '1', { + nx: true, + ex: NOTIF_SEEN_TTL_SECONDS, + }); + return res === 'OK'; +} + +/** Adds a proposal id to the user's pending-vote set and refreshes TTL. Ignores no-op on duplicate. */ +export async function addPendingVote(userSpaceId: string, proposalId: string): Promise { + const key = pendingVotesKey(userSpaceId); + const pipeline = redis.pipeline(); + pipeline.sadd(key, proposalId); + pipeline.expire(key, PENDING_VOTES_TTL_SECONDS); + await pipeline.exec(); +} + +/** Removes a proposal id from the user's pending-vote set. */ +export async function removePendingVote(userSpaceId: string, proposalId: string): Promise { + await redis.srem(pendingVotesKey(userSpaceId), proposalId); +} + +type PendingVotesCache = + | { hit: true; proposalIds: string[] } + | { hit: false }; + +/** Returns the cached pending-vote ids for a user, or `hit: false` if no cache exists. */ +export async function getPendingVotes(userSpaceId: string): Promise { + const key = pendingVotesKey(userSpaceId); + const exists = await redis.exists(key); + if (exists === 0) return { hit: false }; + const ids = await redis.smembers(key); + return { hit: true, proposalIds: ids as string[] }; +} + +/** + * Replaces the cached set for a user with the given ids. Used by the read endpoint's + * fallback path when no cache entry exists — repopulates from the authoritative scan. + */ +export async function seedPendingVotes(userSpaceId: string, proposalIds: string[]): Promise { + const key = pendingVotesKey(userSpaceId); + const pipeline = redis.pipeline(); + pipeline.del(key); + if (proposalIds.length > 0) { + const [first, ...rest] = proposalIds; + pipeline.sadd(key, first, ...rest); + } + pipeline.expire(key, PENDING_VOTES_TTL_SECONDS); + await pipeline.exec(); +} + +/** Cached wallet → user_space_id lookup so polls don't hit the subgraph every time. */ +export async function getCachedWalletSpace(walletAddress: string): Promise { + const value = await redis.get(walletSpaceKey(walletAddress)); + return value ?? null; +} + +/** Persists the wallet → user_space_id mapping. Stores the empty string when the wallet has no space. */ +export async function setCachedWalletSpace( + walletAddress: string, + userSpaceId: string | null +): Promise { + await redis.set(walletSpaceKey(walletAddress), userSpaceId ?? '', { + ex: WALLET_SPACE_TTL_SECONDS, + }); +} diff --git a/apps/web/core/notifications/verify-signature.ts b/apps/web/core/notifications/verify-signature.ts new file mode 100644 index 0000000000..8cc9e21892 --- /dev/null +++ b/apps/web/core/notifications/verify-signature.ts @@ -0,0 +1,31 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +/** + * Verifies a gaia notification-service webhook signature. + * + * Gaia sends `X-Geo-Signature: sha256=` where the hex is HMAC-SHA256 of the + * raw request body bytes (per notification-service/TECH_DESIGN.md). The signature + * must be verified against the exact bytes received — never a re-serialized JSON. + */ +export function verifyGeoSignature({ + rawBody, + signatureHeader, + secret, +}: { + rawBody: string; + signatureHeader: string | null; + secret: string; +}): boolean { + if (!signatureHeader) return false; + + const prefix = 'sha256='; + if (!signatureHeader.startsWith(prefix)) return false; + + const provided = signatureHeader.slice(prefix.length).trim(); + if (provided.length === 0) return false; + + const expected = createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex'); + if (expected.length !== provided.length) return false; + + return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(provided, 'hex')); +} diff --git a/apps/web/core/notifications/webhook-payload-schema.ts b/apps/web/core/notifications/webhook-payload-schema.ts new file mode 100644 index 0000000000..6c5d951340 --- /dev/null +++ b/apps/web/core/notifications/webhook-payload-schema.ts @@ -0,0 +1,57 @@ +import { Schema } from 'effect'; + +/** + * Minimal schemas for the gaia notification-service governance events we actually + * act on. Only the fields we read are required; the service may add more fields + * without breaking us. Bounty events and any other `event_type` values are + * accepted + ignored at the route level (we 2xx them so the delivery worker + * doesn't retry). + * + * Source: gaia/notification-service/WEBHOOK_INTEGRATION.md + */ + +const ProposalCreatedSchema = Schema.Struct({ + version: Schema.Number, + event_type: Schema.Literal('proposal_created'), + category: Schema.Literal('governance'), + user_space_id: Schema.String, + proposal_id: Schema.String, + idempotency_key: Schema.String, +}); + +const ProposalVotedSchema = Schema.Struct({ + version: Schema.Number, + event_type: Schema.Literal('proposal_voted'), + category: Schema.Literal('governance'), + user_space_id: Schema.String, + proposal_id: Schema.String, + idempotency_key: Schema.String, + voter_id: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const ProposalExecutedSchema = Schema.Struct({ + version: Schema.Number, + event_type: Schema.Literal('proposal_executed'), + category: Schema.Literal('governance'), + user_space_id: Schema.String, + proposal_id: Schema.String, + idempotency_key: Schema.String, +}); + +const ProposalRejectedSchema = Schema.Struct({ + version: Schema.Number, + event_type: Schema.Literal('proposal_rejected'), + category: Schema.Literal('governance'), + user_space_id: Schema.String, + proposal_id: Schema.String, + idempotency_key: Schema.String, +}); + +export const RedDotWebhookPayloadSchema = Schema.Union( + ProposalCreatedSchema, + ProposalVotedSchema, + ProposalExecutedSchema, + ProposalRejectedSchema +); + +export type RedDotWebhookPayload = Schema.Schema.Type; diff --git a/apps/web/design-system/button.tsx b/apps/web/design-system/button.tsx index f879a25614..076b9cd238 100644 --- a/apps/web/design-system/button.tsx +++ b/apps/web/design-system/button.tsx @@ -30,14 +30,14 @@ const buttonClassNames = (className = '') => variants: { variant: { primary: - 'border-transparent bg-ctaPrimary text-white hover:bg-ctaHover focus:border-ctaHover focus:shadow-inner-ctaHover', + 'border-transparent bg-ctaPrimary text-white hover:bg-ctaHover focus-visible:border-ctaHover focus-visible:shadow-inner-ctaHover', secondary: - 'border-grey-02 bg-white text-text shadow-button hover:border-text hover:bg-bg hover:text-text! focus:border-text focus:shadow-inner-text', + 'border-grey-02 bg-white text-text shadow-button hover:border-text hover:bg-bg hover:text-text! focus-visible:border-text focus-visible:shadow-inner-text', tertiary: 'border-white bg-text text-white shadow-none', ghost: - 'border-transparent bg-white text-grey-04! shadow-none hover:border-text hover:bg-bg hover:text-text! hover:shadow-button focus:border-text focus:shadow-inner-text', + 'border-transparent bg-white text-grey-04! shadow-none hover:border-text hover:bg-bg hover:text-text! hover:shadow-button focus-visible:border-text focus-visible:shadow-inner-text', transparent: - 'border-text bg-transparent text-text! shadow-none hover:border-text hover:text-text! focus:border-text focus:shadow-inner-text', + 'border-text bg-transparent text-text! shadow-none hover:border-text hover:text-text! focus-visible:border-text focus-visible:shadow-inner-text', success: 'border-white bg-green text-white shadow-none transition-colors duration-150 hover:bg-green/80', error: 'border-white bg-red-01 text-white shadow-none transition-colors duration-150 hover:bg-red-01/80', done: 'border-green bg-green text-text transition-colors duration-150 hover:bg-green/80', diff --git a/apps/web/partials/active-proposal/accept-or-reject.tsx b/apps/web/partials/active-proposal/accept-or-reject.tsx index d8dda4b502..619bf37f9e 100644 --- a/apps/web/partials/active-proposal/accept-or-reject.tsx +++ b/apps/web/partials/active-proposal/accept-or-reject.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { useState } from 'react'; +import { useRouter } from 'next/navigation'; import { useAccessControl } from '~/core/hooks/use-access-control'; import { useSmartAccount } from '~/core/hooks/use-smart-account'; @@ -11,6 +12,7 @@ import { SubstreamVote } from '~/core/io/substream-schema'; import { Button } from '~/design-system/button'; import { Pending } from '~/design-system/pending'; +import { useAddOptimisticVote, useConfirmVote, useRemoveOptimisticVote } from '~/partials/governance/optimistic-voted-atom'; import { GovernanceReopenEditButton } from '~/partials/governance/governance-reopen-edit-button'; import { Execute } from './execute'; @@ -35,6 +37,7 @@ export function AcceptOrReject({ userVote, proposalId, }: Props) { + const router = useRouter(); const { isEditor } = useAccessControl(spaceId); const { vote, status: voteStatus } = useVote({ spaceId, @@ -49,14 +52,29 @@ export function AcceptOrReject({ const isPendingRejection = hasRejected && voteStatus === 'pending'; const { smartAccount } = useSmartAccount(); + const addOptimisticVote = useAddOptimisticVote(); + const removeOptimisticVote = useRemoveOptimisticVote(); + const confirmVote = useConfirmVote(); + + const onVoteSuccess = () => { + confirmVote(proposalId); + router.refresh(); + }; + + const onVoteError = () => { + removeOptimisticVote(proposalId); + }; + const onApprove = () => { setHasApproved(true); - vote('ACCEPT'); + addOptimisticVote(proposalId); + vote('ACCEPT', { onSuccess: onVoteSuccess, onError: onVoteError }); }; const onReject = () => { setHasRejected(true); - vote('REJECT'); + addOptimisticVote(proposalId); + vote('REJECT', { onSuccess: onVoteSuccess, onError: onVoteError }); }; if (isProposalEnded) { diff --git a/apps/web/partials/browse-sidebar/browse-sidebar.tsx b/apps/web/partials/browse-sidebar/browse-sidebar.tsx index e12e803860..79b0d56846 100644 --- a/apps/web/partials/browse-sidebar/browse-sidebar.tsx +++ b/apps/web/partials/browse-sidebar/browse-sidebar.tsx @@ -20,6 +20,8 @@ import { ChevronRight } from '~/design-system/icons/chevron-right'; import { ThumbGeoImage } from '~/design-system/geo-image'; import { PrefetchLink as Link } from '~/design-system/prefetch-link'; +import { useConfirmedVotedIds } from '~/partials/governance/optimistic-voted-atom'; + import { loadBrowseSidebarData } from './load-browse-sidebar-data'; /** Open on first visit; after the user toggles, `browseSidebarOpen` in localStorage wins. */ @@ -59,13 +61,17 @@ function BrowseNavIcon({ src }: { src: string }) { function BrowseNavPrimaryLinks({ personalSpaceId, documentationImage, + pendingVoteProposalIds, }: { personalSpaceId: string | null; documentationImage: string | null; + pendingVoteProposalIds: string[]; }) { const { smartAccount } = useSmartAccount(); const address = smartAccount?.account.address; const { profile } = useGeoProfile(address); + const confirmedVotedIds = useConfirmedVotedIds(); + const hasPendingVotes = pendingVoteProposalIds.some(id => !confirmedVotedIds.has(id)); return ( <> @@ -85,10 +91,18 @@ function BrowseNavPrimaryLinks({ Personal space ) : null} - - - Governance - + {address ? ( + + + Governance + {hasPendingVotes ? ( + <> + Pending votes +
@@ -208,6 +236,7 @@ type GovernanceProposal = { endTime: number; status: ProposalStatus; canExecute: boolean; + bucket: ProposalBucket; proposalVotes: { totalCount: number; yesCount: number; @@ -218,6 +247,7 @@ type GovernanceProposal = { function apiProposalToGovernanceDto( proposal: ApiProposalListItem, + bucket: ProposalBucket, maybeProfile?: Profile, maybeTargetProfile?: Profile ): GovernanceProposal { @@ -236,6 +266,7 @@ function apiProposalToGovernanceDto( endTime: proposal.timing.endTime, status: mapProposalStatus(proposal.status), canExecute: proposal.canExecute, + bucket, createdBy: profile, targetProfile: maybeTargetProfile, userVote: proposal.userVote ? convertVoteOption(proposal.userVote) : undefined, @@ -247,6 +278,12 @@ function apiProposalToGovernanceDto( }; } +function getProposalBucket(apiStatus: string): ProposalBucket { + if (apiStatus === 'EXECUTABLE') return 'executable'; + if (apiStatus === 'PROPOSED') return 'active'; + return 'completed'; +} + /** * Fetch proposals by status using server-side filtering. * Returns proposals filtered and sorted by the API. @@ -352,8 +389,12 @@ async function fetchGovernanceProposals({ }), ]); - // Combine in priority order: executable > active > completed - let combinedProposals = [...executableProposals, ...activeProposals, ...completedProposals]; + // Combine in priority order: executable > active > completed; within open phases, unvoted first. + let combinedProposals = [ + ...sortOpenProposalsUnvotedFirstByEndTimeAsc(executableProposals), + ...sortOpenProposalsUnvotedFirstByEndTimeAsc(activeProposals), + ...completedProposals, + ]; // Filter by proposal type if (effectiveType === 'proposals') { @@ -394,7 +435,7 @@ async function fetchGovernanceProposals({ const maybeProfile = profilesBySpaceId.get(p.proposedBy); const targetId = p.actions[0]?.targetId; const maybeTargetProfile = targetId ? targetProfilesBySpaceId.get(targetId) : undefined; - return apiProposalToGovernanceDto(p, maybeProfile, maybeTargetProfile); + return apiProposalToGovernanceDto(p, getProposalBucket(p.status), maybeProfile, maybeTargetProfile); }); return { proposals, hasMore }; diff --git a/apps/web/partials/governance/optimistic-voted-atom.ts b/apps/web/partials/governance/optimistic-voted-atom.ts new file mode 100644 index 0000000000..a43898d82c --- /dev/null +++ b/apps/web/partials/governance/optimistic-voted-atom.ts @@ -0,0 +1,54 @@ +'use client'; + +import { atom, useAtomValue, useSetAtom } from 'jotai'; + +/** Proposal ids whose card should already appear as "voted" (sunk to bottom of list). Written on click. */ +const optimisticVotedIdsAtom = atom>(new Set()); + +/** Proposal ids whose vote mutation resolved successfully. Written when useVote reports success. */ +const confirmedVotedIdsAtom = atom>(new Set()); + +export function useAddOptimisticVote() { + const setter = useSetAtom(optimisticVotedIdsAtom); + return (proposalId: string) => { + setter(prev => { + if (prev.has(proposalId)) return prev; + const next = new Set(prev); + next.add(proposalId); + return next; + }); + }; +} + +export function useRemoveOptimisticVote() { + const setter = useSetAtom(optimisticVotedIdsAtom); + return (proposalId: string) => { + setter(prev => { + if (!prev.has(proposalId)) return prev; + const next = new Set(prev); + next.delete(proposalId); + return next; + }); + }; +} + +export function useConfirmVote() { + const setter = useSetAtom(confirmedVotedIdsAtom); + return (proposalId: string) => { + setter(prev => { + if (prev.has(proposalId)) return prev; + const next = new Set(prev); + next.add(proposalId); + return next; + }); + }; +} + +export function useIsOptimisticallyVoted(proposalId: string): boolean { + const set = useAtomValue(optimisticVotedIdsAtom); + return set.has(proposalId); +} + +export function useConfirmedVotedIds(): Set { + return useAtomValue(confirmedVotedIdsAtom); +} diff --git a/apps/web/partials/governance/proposal-list-item.tsx b/apps/web/partials/governance/proposal-list-item.tsx new file mode 100644 index 0000000000..41612e4bef --- /dev/null +++ b/apps/web/partials/governance/proposal-list-item.tsx @@ -0,0 +1,20 @@ +'use client'; + +import * as React from 'react'; + +import { useIsOptimisticallyVoted } from './optimistic-voted-atom'; + +interface Props { + proposalId: string; + baseOrder: number; + canSink: boolean; + children: React.ReactNode; +} + +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
{children}
; +}