Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
c8e18ad
feat: add comments API contract and MaiAPIClient methods
royalaid Apr 30, 2026
8a6b9d7
feat: useSiweSession hook for QIP comment auth
royalaid Apr 30, 2026
4f355be
feat: useComments + useIsEditor hooks for QIP comment UI
royalaid Apr 30, 2026
2d204e5
feat: comment list, sanitized markdown render, and moderation menu
royalaid Apr 30, 2026
b41f533
feat: SIWE login, comment composer, and Comments shell
royalaid Apr 30, 2026
7878f80
feat: mount the Comments surface on the QIP detail page
royalaid Apr 30, 2026
97f5a6d
fix: anonymous comment reads use credentials: 'omit'
royalaid Apr 30, 2026
7649553
fix: credentials: 'omit' on every comments fetch path
royalaid Apr 30, 2026
a4980f0
fix: lift SIWE session state to a module-level singleton
royalaid Apr 30, 2026
f975b23
fix: vite dev binds IPv4 and honors PORT for proxy compatibility
royalaid Apr 30, 2026
4b6689c
chore: trigger Vercel preview build for feat/qip-comments
royalaid Apr 30, 2026
e861395
fix: SPA routing — rewrite all non-asset paths to /index.html
royalaid Apr 30, 2026
7da0492
chore: trigger preview rebuild to pick up updated Vercel env vars
royalaid Apr 30, 2026
69d3f6b
feat(comments): multi-chain SIWE — accept Safes on any qidao.eth chain
royalaid Apr 30, 2026
b50e4b6
feat(comments): probe Safe Transaction Service to pick the SIWE chainId
royalaid Apr 30, 2026
5be89a7
fix(comments): pass checksummed address to Safe Transaction Service
royalaid Apr 30, 2026
dde64c2
feat(comments): switch Safe-deployment lookup to mai-api endpoint
royalaid Apr 30, 2026
138d911
feat(comments): switch wallet chain to the Safe's home chain before s…
royalaid May 5, 2026
aed93cf
feat(comments): use 'aveQi' label and drop duplicate wallet button
royalaid May 5, 2026
b853bce
feat(comments): graceful degradation when /v2/comments fails
royalaid May 8, 2026
ac6ae33
fix(qci): show moderator UI when status is Posted to Snapshot
royalaid May 8, 2026
4720907
feat: useEnsureChain hook for pre-tx chain enforcement
royalaid May 8, 2026
f12b237
feat(snapshot-moderator): enforce Base chain before write
royalaid May 8, 2026
7202052
feat(snapshot-submitter): enforce Base chain before linkSnapshotProposal
royalaid May 8, 2026
9083500
feat(status-mutation): enforce Base chain before updateStatus
royalaid May 8, 2026
ee5f65e
feat(rpc): per-chain viem.fallback pools with observability and exhau…
royalaid May 9, 2026
8c163aa
feat(rpc): wire Web3Provider through buildChainTransport
royalaid May 9, 2026
1b002ec
refactor(rpc): migrate qciClient to buildChainTransport, delete loadB…
royalaid May 9, 2026
1ff73a3
feat(rpc): expand per-chain pool defaults from chainlist; prune empir…
royalaid May 9, 2026
6d3d913
fix(rpc): gate QCIClient rpcUrlOverride on localhost so production UR…
royalaid May 9, 2026
8c37b40
fix(comments): persist SIWE session to localStorage so reloads keep t…
royalaid May 9, 2026
ce61c6e
fix(comments): recover SIWE sign-in from popup-dismissal and silent r…
royalaid May 9, 2026
acfeb89
feat(comments): instant POST visibility + optimistic self-delete in u…
royalaid May 9, 2026
f963796
feat(comments): self-delete UI for comment authors
royalaid May 9, 2026
0144c2d
fix(comments): show Delete and Hide together when viewer is both edit…
royalaid May 9, 2026
a64a389
ci(deploy): plumb VITE_BASE_RPC_URLS + VITE_QIP_COMMENTS_BODY_MAX_BYTES
royalaid May 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ VITE_BASE_RPC_URL=http://localhost:8545
VITE_USE_TESTNET=false
VITE_WALLETCONNECT_PROJECT_ID=dummy-project-id

# Optional per-chain RPC overrides for the wagmi transports. Empty falls
# through to viem's chain-default public RPC (fine for the connect-time
# state reads we do; the SIWE-verify hot path runs on the mai-api side
# with its own per-chain RPCs). Add overrides here if a chain's public
# RPC rate-limits your dev/preview deploys. The chains below mirror
# Snapshot's qidao.eth voting matrix (https://snapshot.box/#/s:qidao.eth)
# so any wallet that can vote on a proposal can sign in here.
VITE_BASE_SEPOLIA_RPC_URL=
VITE_MAINNET_RPC_URL=
VITE_OPTIMISM_RPC_URL=
VITE_GNOSIS_RPC_URL=
VITE_POLYGON_RPC_URL=
VITE_ARBITRUM_RPC_URL=

# IPFS Configuration (Mai API is primary method)
VITE_USE_MAI_API=true
VITE_IPFS_API_URL=http://localhost:3001/v2/ipfs-upload
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/deploy-to-gh.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,18 @@ jobs:
# Required secrets for blockchain and Web3 functionality
VITE_QCI_REGISTRY_ADDRESS: ${{ secrets.QCI_REGISTRY_ADDRESS }}
VITE_BASE_RPC_URL: ${{ secrets.BASE_RPC_URL }}
# Multi-RPC pool for the viem.fallback Base transport. Comma-separated
# list; rank-first wins. Empty falls through to the chainlist defaults
# in src/utils/rpcPools.ts (curated, with CORS-broken endpoints pruned).
VITE_BASE_RPC_URLS: ${{ secrets.BASE_RPC_URLS }}
VITE_WALLETCONNECT_PROJECT_ID: ${{ secrets.WALLETCONNECT_PROJECT_ID }}

# Required for IPFS uploads via Mai API
VITE_IPFS_API_URL: ${{ secrets.IPFS_API_URL }}

# Frontend body byte cap; must match the API's QIP_COMMENTS_BODY_MAX_BYTES
# so client and server reject the same inputs. Defaults to 8192 (8 KB).
VITE_QIP_COMMENTS_BODY_MAX_BYTES: ${{ secrets.QIP_COMMENTS_BODY_MAX_BYTES || '8192' }}

# Mai API endpoint for fetching QCIs (24x performance improvement)
VITE_MAI_API_URL: ${{ vars.VITE_MAI_API_URL || secrets.MAI_API_URL || 'https://api.mai.finance' }}
Expand Down
16 changes: 16 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"author": "Tekrajs & Publu",
"keywords": [],
"scripts": {
"dev": "vite",
"dev": "vite --host ${HOST:-127.0.0.1} --port ${PORT:-3000}",
"dev:local": "./scripts/start-pure-local.sh",
"dev:local:migrate": "./scripts/start-pure-local.sh --migrate",
"dev:api": "./scripts/start-api-mode.sh",
Expand Down Expand Up @@ -58,12 +58,13 @@
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"siwe": "^3.0.0",
"sonner": "^2.0.7",
"swr": "^2.2.4",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^3.3.3",
"tailwindcss-animate": "^1.0.7",
"viem": "2.x",
"viem": "~2.30.0",
"wagmi": "^2.15.5"
},
"overrides": {
Expand Down
4 changes: 4 additions & 0 deletions portless.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "qips.qidao",
"script": "dev"
}
114 changes: 114 additions & 0 deletions src/components/Comments/CommentComposer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import React, { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { config } from '@/config/env';
import { useComments } from '@/hooks/useComments';
import { useSiweSession } from '@/hooks/useSiweSession';

interface CommentComposerProps {
qciId: number;
}

const WARNING_RATIO = 0.8;

const ENCODER = new TextEncoder();
function utf8ByteLength(s: string): number {
return ENCODER.encode(s).length;
}

function formatNumber(s: string): string {
// The API returns vp/threshold as decimal strings; render with thousand
// separators when the integer part is large, leave the fractional part
// untouched.
if (!/^-?\d+(\.\d+)?$/.test(s)) return s;
const [intPart, fracPart] = s.split('.');
const formatted = Number(intPart).toLocaleString();
return fracPart ? `${formatted}.${fracPart}` : formatted;
}

/**
* Gated comment composer. Lives below CommentList; only renders when the
* caller has confirmed the user has an authenticated session — see
* <Comments> for the wiring.
*/
export const CommentComposer: React.FC<CommentComposerProps> = ({ qciId }) => {
const [body, setBody] = useState('');
const { postComment, isPosting } = useComments(qciId);
const { sessionToken, clearOn401 } = useSiweSession();

const maxBytes = config.qipCommentsBodyMaxBytes;
const byteLength = useMemo(() => utf8ByteLength(body), [body]);
const overLimit = byteLength > maxBytes;
const nearLimit = !overLimit && byteLength >= Math.floor(maxBytes * WARNING_RATIO);
const trimmedEmpty = body.trim().length === 0;
const submitDisabled = isPosting || trimmedEmpty || overLimit;

const handleSubmit = async () => {
if (submitDisabled) return;

const result = await postComment({ body, sessionToken });

if (result.ok) {
setBody('');
toast.success('Comment posted.');
return;
}

// Failure paths — keep the textarea so the user can edit and retry.
switch (result.status) {
case 401:
clearOn401();
toast.error('Your session expired. Please sign in again.');
break;
case 403:
toast.error(
`You need ${formatNumber(result.threshold)} aveQi to comment. ` +
`You have ${formatNumber(result.currentVp)}.`,
);
break;
case 413:
toast.error(`Comment too long. Maximum is ${result.maxBytes.toLocaleString()} bytes.`);
break;
case 429:
toast.error("You're posting too fast. Please try again in a minute.");
break;
case 503:
toast.error('Vote-power service is unavailable right now. Please try again shortly.');
break;
default:
toast.error(`Couldn't post comment: ${result.error}`);
}
};

return (
<div className="space-y-2">
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Share your thoughts on this proposal…"
rows={4}
className="min-h-[100px]"
disabled={isPosting}
/>
<div className="flex items-center justify-between text-xs">
<span
className={
overLimit
? 'text-destructive'
: nearLimit
? 'text-amber-600 dark:text-amber-400'
: 'text-muted-foreground'
}
aria-live="polite"
>
{byteLength.toLocaleString()} / {maxBytes.toLocaleString()} bytes
{overLimit && ' — too long'}
</span>
<Button onClick={handleSubmit} disabled={submitDisabled} size="sm">
{isPosting ? 'Posting…' : 'Post'}
</Button>
</div>
</div>
);
};
Loading