-
- {idValue && (
-
- {idValue}
-
- )}
-
- {proposedTimeValue && (
-
{`Proposed ${proposedTimeValue}`}
- )}
-
- {titleValue &&
{titleValue}
}
+
+
+ {titleValue &&
{titleValue}
}
{votes && sum > MIN_VOTE_SUM_FOR_GRAPH && (
@@ -71,6 +63,62 @@ export function ProposalCard({ data }: { data: MergedProposalData }) {
{`${endTimeLabel} ${endTimeValue}`}
)}
+
+ );
+}
+
+export function ProposalBadgeRow({
+ data,
+ showProposer,
+}: {
+ data: MergedProposalData;
+ showProposer?: boolean;
+}) {
+ const { stage, proposal, metadata } = data;
+
+ const { id, timestamp, proposer } = proposal || {};
+ const { timestamp: cgpTimestamp, cgp } = metadata || {};
+
+ const proposedTimestamp = timestamp || cgpTimestamp;
+ const proposedTimeValue = proposedTimestamp
+ ? new Date(proposedTimestamp).toLocaleDateString()
+ : undefined;
+
+ return (
+
+
+
+ {proposedTimeValue && (
+
{`Proposed ${proposedTimeValue}`}
+ )}
+ {showProposer && proposer && (
+ <>
+
•
+
{shortenAddress(proposer)}
+ >
+ )}
+
+ );
+}
+
+function IdBadge({ cgp, id }: { cgp?: number; id?: number }) {
+ if (!cgp && !id) return null;
+ const idValue = cgp ? `CGP ${cgp}` : `# ${id}`;
+ return (
+
+ {idValue}
+
+ );
+}
+
+function StageBadge({ stage }: { stage: ProposalStage }) {
+ const { color, label } = ProposalStageToStyle[stage];
+ return (
+
+ {label}
);
}
diff --git a/src/features/governance/fetchFromRepository.ts b/src/features/governance/fetchFromRepository.ts
index f8c46e2..68a2795 100644
--- a/src/features/governance/fetchFromRepository.ts
+++ b/src/features/governance/fetchFromRepository.ts
@@ -1,3 +1,4 @@
+import { sanitize } from 'dompurify';
import { micromark } from 'micromark';
import {
MetadataStatusToStage,
@@ -6,6 +7,7 @@ import {
} from 'src/features/governance/repoTypes';
import { logger } from 'src/utils/logger';
import { objLength } from 'src/utils/objects';
+import { isNullish } from 'src/utils/typeof';
import { parse as parseYaml } from 'yaml';
// TODO use official repo when fixes are merged
@@ -18,7 +20,8 @@ const GITHUB_BRANCH = 'missing-proposal-ids';
const GITHUB_NAME_REGEX = /^cgp-(\d+)\.md$/;
export async function fetchProposalsFromRepo(
- cache: ProposalMetadata[] = [],
+ cache: ProposalMetadata[],
+ validateMarkdown: boolean,
): Promise
{
const files = await fetchGithubDirectory(
GITHUB_OWNER,
@@ -45,7 +48,7 @@ export async function fetchProposalsFromRepo(
}
// If it's not in the cache, fetch the file and parse it
- const content = await fetchGithubFile(file);
+ const content = await fetchGithubFile(file.download_url);
if (!content) {
errorUrls.push(file.download_url);
continue;
@@ -60,8 +63,9 @@ export async function fetchProposalsFromRepo(
const { frontMatter, body } = fileParts;
logger.debug('Front matter size', objLength(frontMatter), 'body size', body.length);
- const proposalMetadata = parseFontMatter(frontMatter);
- if (!proposalMetadata || !isValidBody(body)) {
+ const proposalMetadata = parseFontMatter(frontMatter, file.download_url);
+ const bodyValid = validateMarkdown ? !isNullish(markdownToHtml(body)) : true;
+ if (!proposalMetadata || !bodyValid) {
errorUrls.push(file.download_url);
continue;
}
@@ -75,6 +79,22 @@ export async function fetchProposalsFromRepo(
return validProposals;
}
+export async function fetchProposalContent(url: string) {
+ const content = await fetchGithubFile(url);
+ if (!content) throw new Error('Failed to fetch proposal content');
+ const fileParts = separateYamlFrontMatter(content);
+ if (!fileParts) throw new Error('Failed to parse proposal content');
+ const markup = markdownToHtml(fileParts.body);
+ if (isNullish(markup)) throw new Error('Failed to convert markdown to html');
+ if (!markup) {
+ logger.warn('Content is empty for:', url);
+ return '';
+ }
+ // Client-side only due to issue with DomPurify in SSR
+ if (typeof window !== 'undefined') return sanitize(markup);
+ else return '';
+}
+
interface GithubFile {
name: string;
path: string;
@@ -117,14 +137,14 @@ async function fetchGithubDirectory(
}
}
-async function fetchGithubFile(file: GithubFile): Promise {
+async function fetchGithubFile(url: string): Promise {
try {
- logger.debug('Fetching github file', file.download_url);
- const response = await fetch(file.download_url);
+ logger.debug('Fetching github file', url);
+ const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
return await response.text();
} catch (error) {
- logger.error('Error fetching github file', file.name, error);
+ logger.error('Error fetching github file', url, error);
return null;
}
}
@@ -145,11 +165,12 @@ function separateYamlFrontMatter(content: string) {
}
}
-function parseFontMatter(data: Record): ProposalMetadata | null {
+function parseFontMatter(data: Record, url: string): ProposalMetadata | null {
try {
const parsed = RawProposalMetadataSchema.parse(data);
return {
cgp: parsed.cgp,
+ cgpUrl: url,
title: parsed.title,
author: parsed.author,
stage: MetadataStatusToStage[parsed.status],
@@ -166,11 +187,10 @@ function parseFontMatter(data: Record): ProposalMetadata | null
}
}
-function isValidBody(body: string) {
+function markdownToHtml(body: string) {
try {
// Attempt conversion from markdown to html
- micromark(body);
- return true;
+ return micromark(body);
} catch (error) {
logger.error('Error converting markdown', error);
return null;
diff --git a/src/features/governance/repoTypes.ts b/src/features/governance/repoTypes.ts
index 31362f8..a009c0c 100644
--- a/src/features/governance/repoTypes.ts
+++ b/src/features/governance/repoTypes.ts
@@ -42,12 +42,13 @@ export const RawProposalMetadataSchema = z.object({
export interface ProposalMetadata {
// Overlapping data with Proposal interface
stage: ProposalStage;
- id?: number;
- timestamp?: number;
- url?: string;
+ id?: number; // on-chain id
+ timestamp?: number; // create time
+ url?: string; // aka discussion url
// Extra metadata
- cgp: number;
+ cgp: number; // cgp id (different than on-chain)
+ cgpUrl: string; // url in repo
title: string;
author: string;
timestampExecuted?: number;
diff --git a/src/features/governance/useGovernanceProposals.ts b/src/features/governance/useGovernanceProposals.ts
index c1cdbac..27a23ba 100644
--- a/src/features/governance/useGovernanceProposals.ts
+++ b/src/features/governance/useGovernanceProposals.ts
@@ -160,7 +160,7 @@ function fetchGovernanceMetadata(): Promise {
// fetches them at build time and stores a cache. To keep this
// hook fast, the app should be re-built every now and then.
const cached = CachedMetadata as ProposalMetadata[];
- return fetchProposalsFromRepo(cached);
+ return fetchProposalsFromRepo(cached, false);
}
function mergeProposalsWithMetadata(
diff --git a/src/features/governance/useProposalContent.ts b/src/features/governance/useProposalContent.ts
new file mode 100644
index 0000000..0d82304
--- /dev/null
+++ b/src/features/governance/useProposalContent.ts
@@ -0,0 +1,27 @@
+import { useQuery } from '@tanstack/react-query';
+import { useToastError } from 'src/components/notifications/useToastError';
+import { fetchProposalContent } from 'src/features/governance/fetchFromRepository';
+import { ProposalMetadata } from 'src/features/governance/repoTypes';
+import { logger } from 'src/utils/logger';
+
+export function useProposalContent(metadata?: ProposalMetadata) {
+ const url = metadata?.cgpUrl;
+ const { isLoading, isError, error, data } = useQuery({
+ queryKey: ['useProposalContent', url],
+ queryFn: () => {
+ if (!url) return null;
+ logger.debug('Fetching proposal content', url);
+ return fetchProposalContent(url);
+ },
+ gcTime: Infinity,
+ staleTime: 60 * 60 * 1000, // 1 hour
+ });
+
+ useToastError(error, 'Error fetching proposal content');
+
+ return {
+ isLoading,
+ isError,
+ content: data || undefined,
+ };
+}
diff --git a/src/scripts/collectProposalMetadata.ts b/src/scripts/collectProposalMetadata.ts
index 312b22a..0c93c83 100644
--- a/src/scripts/collectProposalMetadata.ts
+++ b/src/scripts/collectProposalMetadata.ts
@@ -8,7 +8,7 @@ const PROPOSALS_OUT_PATH = path.resolve(__dirname, '../config/proposals.json');
async function main() {
logger.info('Fetching list of proposals');
- const proposals = await fetchProposalsFromRepo();
+ const proposals = await fetchProposalsFromRepo([], true);
logger.info(`Writing proposals to file ${PROPOSALS_OUT_PATH}`);
fs.writeFileSync(PROPOSALS_OUT_PATH, JSON.stringify(proposals, null, 2), 'utf8');
diff --git a/yarn.lock b/yarn.lock
index 961d23a..8f341c6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -461,6 +461,7 @@ __metadata:
"@tanstack/eslint-plugin-query": "npm:^5.17.7"
"@tanstack/react-query": "npm:^5.17.10"
"@tanstack/react-table": "npm:^8.11.6"
+ "@types/dompurify": "npm:^3"
"@types/jest": "npm:^29.5.11"
"@types/node": "npm:^20.10.4"
"@types/react": "npm:^18.2.45"
@@ -473,6 +474,7 @@ __metadata:
clsx: "npm:^2.0.0"
critters: "npm:^0.0.20"
daisyui: "npm:^4.4.20"
+ dompurify: "npm:^3.0.8"
eslint: "npm:^8.55.0"
eslint-config-next: "npm:^14.1.0"
eslint-config-prettier: "npm:^9.1.0"
@@ -2413,6 +2415,15 @@ __metadata:
languageName: node
linkType: hard
+"@types/dompurify@npm:^3":
+ version: 3.0.5
+ resolution: "@types/dompurify@npm:3.0.5"
+ dependencies:
+ "@types/trusted-types": "npm:*"
+ checksum: e544b3ce53c41215cabff3d89256ff707c7ee8e0c9a1b5034b22014725d288b16e6942cdcdeeb4221c578c3421a6a4721aa0676431f55d7abd18c07368855c5e
+ languageName: node
+ linkType: hard
+
"@types/filesystem@npm:*":
version: 0.0.35
resolution: "@types/filesystem@npm:0.0.35"
@@ -2591,7 +2602,7 @@ __metadata:
languageName: node
linkType: hard
-"@types/trusted-types@npm:^2.0.2":
+"@types/trusted-types@npm:*, @types/trusted-types@npm:^2.0.2":
version: 2.0.7
resolution: "@types/trusted-types@npm:2.0.7"
checksum: 8e4202766a65877efcf5d5a41b7dd458480b36195e580a3b1085ad21e948bc417d55d6f8af1fd2a7ad008015d4117d5fdfe432731157da3c68678487174e4ba3
@@ -4958,6 +4969,13 @@ __metadata:
languageName: node
linkType: hard
+"dompurify@npm:^3.0.8":
+ version: 3.0.8
+ resolution: "dompurify@npm:3.0.8"
+ checksum: 671fa18bd4bcb1a6ff2e59ecf919f807615b551e7add8834b27751d4e0f3d754a67725482d1efdd259317cadcaaccb72a8afc3aba829ac59730e760041591a1a
+ languageName: node
+ linkType: hard
+
"domutils@npm:^3.0.1":
version: 3.1.0
resolution: "domutils@npm:3.1.0"