diff --git a/schemas/historicalWinnerClaim.gql b/schemas/historicalWinnerClaim.gql index b7dda9c1..d60acbde 100644 --- a/schemas/historicalWinnerClaim.gql +++ b/schemas/historicalWinnerClaim.gql @@ -24,6 +24,8 @@ query HistoricalWinnerClaim($humanityId: Bytes!, $lastStatusChange: BigInt!) { evidenceGroup { evidence(orderBy: creationTime, orderDirection: asc, first: 1) { uri + creationTime + submitter } } } diff --git a/src/app/[pohid]/(profile)/page.tsx b/src/app/[pohid]/(profile)/page.tsx index a1a8d31c..3c5165b0 100644 --- a/src/app/[pohid]/(profile)/page.tsx +++ b/src/app/[pohid]/(profile)/page.tsx @@ -37,7 +37,11 @@ async function Profile({ params }: PageProps) {

POH ID

{displayId} - +

diff --git a/src/app/[pohid]/RevokeModal.tsx b/src/app/[pohid]/RevokeModal.tsx index 4b40d0d3..09af3044 100644 --- a/src/app/[pohid]/RevokeModal.tsx +++ b/src/app/[pohid]/RevokeModal.tsx @@ -86,9 +86,9 @@ export default function RevokeModal({ loading.stop(); toast.error("Revoke is not available right now."); }, - onError(error) { + onError(error, errorCtx) { loading.stop(); - toast.error(getWriteErrorMessage(error)); + toast.error(getWriteErrorMessage(error, errorCtx)); }, onSuccess() { applyAction("revoke", buildRevokeSuccessPatch()); diff --git a/src/app/[pohid]/[chain]/[request]/ActionBar.tsx b/src/app/[pohid]/[chain]/[request]/ActionBar.tsx index af5f38f9..44b5f513 100644 --- a/src/app/[pohid]/[chain]/[request]/ActionBar.tsx +++ b/src/app/[pohid]/[chain]/[request]/ActionBar.tsx @@ -2,9 +2,17 @@ import { enableReactUse } from "@legendapp/state/config/enableReactUse"; import ExternalLink from "components/ExternalLink"; -import StatusIcon from "components/StatusIcon"; +import ExternalLinkIcon from "components/ExternalLinkIcon"; import TimeAgo from "components/TimeAgo"; import ActionButton from "components/ActionButton"; +import IdentityReferenceRow from "components/IdentityReferenceRow"; +import InfoTooltip from "components/InfoTooltip"; +import RequestModal, { + RequestModalActions, + RequestModalHeader, + RequestWarning, +} from "components/RequestModal"; +import StatusBadge from "components/Request/StatusBadge"; import usePoHWrite from "contracts/hooks/usePoHWrite"; import { ContractData } from "data/contract"; import { getMyData } from "data/user"; @@ -12,28 +20,30 @@ import { RequestQuery } from "generated/graphql"; import useChainParam from "hooks/useChainParam"; import useWeb3Loaded from "hooks/useWeb3Loaded"; import type { RequestOptimisticOverlay } from "optimistic/types"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "react-toastify"; import useSWR from "swr"; import { getResolvedRequestStatus, getResolvingRequestStatus, getStatusColor, - getStatusLabel, RequestStatus, } from "utils/status"; import { ActionType } from "utils/enums"; import { Address, Hash, formatEther, hexToSignature } from "viem"; import { useAccount, useChainId } from "wagmi"; -import { idToChain } from "config/chains"; +import { idToChain, type SupportedChain } from "config/chains"; import { resolveTxState } from "utils/txState"; import { useRequestOptimistic } from "optimistic/request"; +import { prettifyId } from "utils/identifier"; +import Image from "next/image"; import Appeal from "./Appeal"; import Challenge from "./Challenge"; import FundButton from "./Funding"; import RemoveVouch from "./RemoveVouch"; -import Vouch from "./Vouch"; +import Vouch, { VouchFlowModal } from "./Vouch"; import { AlertTriangleIcon, InfoCircleIcon } from "./AlertIcons"; +import { getWriteErrorMessage } from "hooks/useActionFeedback"; enableReactUse(); @@ -93,6 +103,135 @@ interface ActionBarProps { anotherClaimPending?: boolean; } +interface VouchingActionsProps { + mode: "vouching" | "advance"; + showFund: boolean; + pohId: Hash; + requester: Address; + index: number; + totalCost: bigint; + funded: bigint; + chain: SupportedChain; + address?: Address; + web3Loaded: boolean; + userChainId: number; + didIVouchFor: boolean; + isVouchOnchain: boolean; + canVouch: boolean; + onVouchClick: () => void; + lockClaimed: boolean; + claimedTooltip: string; + needsVouches: boolean; + withdrawTrigger: { disabled: boolean; tooltip?: string }; + onWithdraw: () => void; +} + +function VouchingActions({ + mode, + showFund, + pohId, + requester, + index, + totalCost, + funded, + chain, + address, + web3Loaded, + userChainId, + didIVouchFor, + isVouchOnchain, + canVouch, + onVouchClick, + lockClaimed, + claimedTooltip, + needsVouches, + withdrawTrigger, + onWithdraw, +}: VouchingActionsProps) { + const isRequester = requester.toLowerCase() === address?.toLowerCase(); + const isVouching = mode === "vouching"; + const fundButton = showFund ? ( + + ) : null; + + if (isRequester) { + const withdrawButton = ( + + ); + + if (!isVouching) return withdrawButton; + + return ( +
+
+ {fundButton} + {withdrawButton} +
+ {needsVouches && ( + + Get a vouch + + + )} +
+ ); + } + + if (!didIVouchFor) { + return ( + <> + {fundButton} + {canVouch && ( + + )} + + ); + } + + if (!isVouching && !isVouchOnchain) return null; + + return ( + <> + {fundButton} + + + ); +} + export default function ActionBar({ pohId, requester, @@ -108,6 +247,9 @@ export default function ActionBar({ anotherClaimPending = false, }: ActionBarProps) { const chain = useChainParam()!; + const [withdrawModalOpen, setWithdrawModalOpen] = useState(false); + const [vouchModalOpen, setVouchModalOpen] = useState(false); + const [appealActive, setAppealActive] = useState(false); const { address } = useAccount(); const { effective, pendingAction, applyAction } = useRequestOptimistic(); const web3Loaded = useWeb3Loaded(); @@ -178,8 +320,8 @@ export default function ActionBar({ "executeRequest", useMemo( () => ({ - onError() { - toast.error("Transaction rejected"); + onError(error, errorCtx) { + toast.error(getWriteErrorMessage(error, errorCtx)); }, onSuccess() { applyAction("execute", buildExecuteSuccessPatch(effectiveRevocation)); @@ -193,8 +335,8 @@ export default function ActionBar({ "advanceState", useMemo( () => ({ - onError() { - toast.error("Transaction rejected"); + onError(error, errorCtx) { + toast.error(getWriteErrorMessage(error, errorCtx)); }, onSuccess() { if (effectiveRevocation) return; @@ -209,11 +351,12 @@ export default function ActionBar({ "withdrawRequest", useMemo( () => ({ - onError() { - toast.error("Transaction rejected"); + onError(error, errorCtx) { + toast.error(getWriteErrorMessage(error, errorCtx)); }, onSuccess() { applyAction("withdraw", buildWithdrawSuccessPatch()); + setWithdrawModalOpen(false); toast.success("Request withdrawn successfully"); }, }), @@ -243,7 +386,10 @@ export default function ActionBar({ message: `Switch your chain above to ${idToChain(chain.id)?.name || "the correct chain"}`, }; const withdrawTrigger = resolveTxState([ - { active: isReconciling, message: "Syncing" }, + // Message-less: keeps the bar trigger disabled while the modal's + // withdraw tx is in flight, without putting it into a loading state. + { active: isWithdrawLoading }, + { active: isReconciling, message: "Waiting for indexer" }, { active: isWithdrawPrepareError, message: "Withdraw not possible, please try again", @@ -253,7 +399,7 @@ export default function ActionBar({ const advanceTrigger = resolveTxState([ { active: lockClaimed, message: claimedTooltip }, { active: !address, message: connectTooltip }, - { active: isReconciling, message: "Syncing" }, + { active: isReconciling, message: "Waiting for indexer" }, { active: isAdvancePrepareError, message: "Advance not possible, please try again", @@ -263,7 +409,7 @@ export default function ActionBar({ const executeTrigger = resolveTxState([ { active: lockClaimed, message: claimedTooltip }, { active: !address, message: connectTooltip }, - { active: isReconciling, message: "Syncing" }, + { active: isReconciling, message: "Waiting for indexer" }, { active: isExecutePrepareError, message: "Execute not possible, please try again", @@ -378,11 +524,39 @@ export default function ActionBar({ }, [prepareWithdraw, withdrawPrepareKey]); const totalCost = BigInt(contractData.baseDeposit) + arbitrationCost; + const isMyRegistrationValid = + !!me?.expirationTime && me.expirationTime > Date.now() / 1000; + const canVouch = + web3Loaded && + !!me && + me.homeChain?.id === chain.id && + !!me.pohId && + isMyRegistrationValid; + const vouchingActionsProps = { + pohId, + requester, + index, + totalCost, + funded: effectiveFunded, + chain, + address, + web3Loaded, + userChainId, + didIVouchFor, + isVouchOnchain, + canVouch, + onVouchClick: () => setVouchModalOpen(true), + lockClaimed, + claimedTooltip, + needsVouches: effectiveValidVouches < contractData.requiredNumberOfVouches, + withdrawTrigger, + onWithdraw: () => setWithdrawModalOpen(true), + } satisfies Omit; const statusColor = getStatusColor(effectiveRequestStatus); return ( -
+
{(lockClaimed || (anotherClaimPending && !effectiveRevocation)) && (
{lockClaimed && ( @@ -408,24 +582,19 @@ export default function ActionBar({ )}
)} -
-
- Status - - - {getStatusLabel(effectiveRequestStatus, "actionBar")} - +
+
+ Status +
-
+
{web3Loaded && (action === ActionType.REMOVE_VOUCH || action === ActionType.VOUCH || action === ActionType.FUND) && ( <>
- + {effectiveValidVouches < contractData.requiredNumberOfVouches && ( <> @@ -455,149 +624,28 @@ export default function ActionBar({
-
- {requester.toLocaleLowerCase() === address?.toLowerCase() ? ( -
-
- {action === ActionType.FUND && ( - - )} - -
- {effectiveValidVouches < - contractData.requiredNumberOfVouches && ( - - Get a vouch - - - - - - - )} -
- ) : !didIVouchFor ? ( - <> - {action === ActionType.FUND && ( - - )} - - - ) : ( - <> - {action === ActionType.FUND && ( - - )} - - - )} +
+
)} {web3Loaded && action === ActionType.ADVANCE && ( <> - + Ready to advance -
- {requester.toLocaleLowerCase() === address?.toLowerCase() ? ( - - ) : !didIVouchFor ? ( - - ) : isVouchOnchain ? ( - - ) : null} +
+ - + Ready to finalize. -
+
-
+
Challenge period end:{" "} - + {pendingAction === "challenge" ? "Challenge confirmed onchain. Waiting for indexed dispute details" : "The request was challenged"} @@ -665,7 +713,7 @@ export default function ActionBar({ <> {" "} for{" "} - + {currentChallenge.reason.id} @@ -674,29 +722,42 @@ export default function ActionBar({ {pendingAction !== "challenge" && currentChallenge && ( -
- +
+ {!appealActive && ( + +

+ When a profile is challenged, a case is opened in Kleros + Court, where jurors review the evidence from both sides + before voting. +

+

+ You can submit evidence for this case from the Evidence + section below. +

+
+ )} - - View case #{currentChallenge.disputeId} - +
+ + + + View case #{currentChallenge.disputeId} + +
)} @@ -710,7 +771,12 @@ export default function ActionBar({ ? "Removal request was rejected" : effectiveRequestStatus === RequestStatus.REJECTED ? "Request was rejected" - : "Request was accepted"} + : effectiveRequestStatus === RequestStatus.PUNISHED_VOUCH + ? "Profile was removed for vouching for a punished profile" + : effectiveRequestStatus === + RequestStatus.RESOLVED_REVOCATION + ? "Profile was removed" + : "Request was accepted"}
+ setWithdrawModalOpen(false)} + canClose={!isWithdrawLoading} + > + + Withdraw this Request + + } + description="Cancel this pending request before it advances beyond the vouching stage." + /> +
+ + POH ID + +
+ + This cancels only the pending request and returns the fees and rewards + available to its requester. It does not remove an existing verified + profile from the Proof of Humanity registry. Previously uploaded + information, such as photos and videos, remains stored on IPFS. + + setWithdrawModalOpen(false)} + returnDisabled={isWithdrawLoading} + primaryLabel={isWithdrawLoading ? "Withdrawing" : "Withdraw"} + onPrimary={withdraw} + primaryDisabled={withdrawTrigger.disabled} + primaryLoading={isWithdrawLoading} + primaryTooltip={withdrawTrigger.tooltip} + primaryClassName="sm:w-[244px]" + /> +
+ {canVouch && effectiveStatus === "vouching" && ( + setVouchModalOpen(false)} + pohId={pohId} + claimer={requester} + chain={chain} + address={address} + /> + )}
); } diff --git a/src/app/[pohid]/[chain]/[request]/Appeal.tsx b/src/app/[pohid]/[chain]/[request]/Appeal.tsx index 3807d5bf..6d743d5e 100644 --- a/src/app/[pohid]/[chain]/[request]/Appeal.tsx +++ b/src/app/[pohid]/[chain]/[request]/Appeal.tsx @@ -1,19 +1,26 @@ "use client"; -import { useEffectOnce } from "@legendapp/state/react"; -import Arrow from "components/Arrow"; -import BulletedNumber from "components/BulletedNumber"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import ExternalLink from "components/ExternalLink"; -import CurrencyField from "components/CurrencyField"; -import Identicon from "components/Identicon"; -import Modal from "components/Modal"; +import ExternalLinkIcon from "components/ExternalLinkIcon"; +import InfoTooltip from "components/InfoTooltip"; +import CurrencyField, { CurrencyIcon } from "components/CurrencyField"; import Progress from "components/Progress"; import TimeAgo from "components/TimeAgo"; import ActionButton from "components/ActionButton"; -import { SupportedChainId, idToChain } from "config/chains"; +import RequestModal, { + RequestModalActions, + RequestAmountPill, + RequestModalHeader, +} from "components/RequestModal"; +import { + SupportedChainId, + explorerTxLink, + idToChain, + nativeCurrencyLabel, +} from "config/chains"; import { APIArbitrator, - ArbitratorsData, DisputeStatusEnum, SideEnum, } from "contracts/apis/APIArbitrator"; @@ -21,99 +28,133 @@ import { APIPoH, StakeMultipliers } from "contracts/apis/APIPoH"; import usePoHWrite from "contracts/hooks/usePoHWrite"; import { RequestQuery } from "generated/graphql"; import { useLoading } from "hooks/useLoading"; -import useEnoughFunds from "hooks/useEnoughFunds"; +import useFundingAmount from "hooks/useFundingAmount"; import { resolveTxState } from "utils/txState"; -import Image from "next/image"; import { useRequestOptimistic } from "optimistic/request"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "react-toastify"; -import { RequestStatus } from "utils/status"; import { formatEth } from "utils/misc"; -import { Address, parseEther } from "viem"; -import { useAccount, useChainId } from "wagmi"; +import { shortenAddress } from "utils/address"; +import { Address, Hash } from "viem"; import { useRouter } from "next/navigation"; +import { getWriteErrorMessage } from "hooks/useActionFeedback"; + +type AppealChallenge = ArrayElement< + NonNullable["request"]>["challenges"] +>; + +const toBigIntOrZero = (amount: bigint | string | number | null | undefined) => + BigInt(amount ?? 0); + +// Stake multipliers are expressed in basis points on the PoH contract. +const MULTIPLIER_DIVISOR = 10_000n; + +// Total amount a side must raise: the raw appeal cost plus that side's stake. +// The stake depends on whether the side is currently winning or losing; on a +// shared ruling ("neither side") both sides pay the shared stake. +const getSideAppealCosts = ( + appealCost: bigint, + currentRulingSide: SideEnum, + { + winnerStakeMultiplier, + loserStakeMultiplier, + sharedStakeMultiplier, + }: StakeMultipliers, +) => { + const withStake = (multiplier: bigint | undefined) => + appealCost + (appealCost * toBigIntOrZero(multiplier)) / MULTIPLIER_DIVISOR; + + if (currentRulingSide === SideEnum.shared) { + const sharedCost = withStake(sharedStakeMultiplier); + return { claimerCost: sharedCost, challengerCost: sharedCost }; + } + + const winnerCost = withStake(winnerStakeMultiplier); + const loserCost = withStake(loserStakeMultiplier); + return currentRulingSide === SideEnum.claimer + ? { claimerCost: winnerCost, challengerCost: loserCost } + : { claimerCost: loserCost, challengerCost: winnerCost }; +}; + +// The subgraph appends a new round as soon as appeal funding starts, while +// nbRounds only counts completed rounds — an extra entry in `rounds` therefore +// means the last round is the in-progress (partially funded) appeal round. +const getPendingAppealRoundFunds = (challenge: AppealChallenge) => { + const hasInProgressAppealRound = + Number(challenge.nbRounds) + 1 === challenge.rounds.length; + if (!hasInProgressAppealRound) + return { claimerFunds: 0n, challengerFunds: 0n }; + + const currentRound = challenge.rounds.at(-1); //latest round + return { + claimerFunds: toBigIntOrZero(currentRound?.requesterFund?.amount), + challengerFunds: toBigIntOrZero(currentRound?.challengerFund?.amount), + }; +}; interface SideFundingProps { side: SideEnum; arbitrator: Address; disputeId: bigint; - requester: Address; - requesterFunds: bigint; + /** The party being crowdfunded (submitter or challenger). */ + partyAddress: Address; + partyFunds: bigint; appealCost: bigint; chainId: SupportedChainId; - loosingSideHasEnd: boolean; - onSuccess?: () => void; - disabled?: boolean; + losingSideDeadlinePassed: boolean; + onSuccess?: (amount: bigint, txHash?: Hash) => void; + onLoadingChange?: (loading: boolean) => void; + isReconciling?: boolean; + fundingPending?: boolean; } const SideFunding: React.FC = ({ side, disputeId, arbitrator, - requester, - requesterFunds, + partyAddress, + partyFunds, appealCost, chainId, - loosingSideHasEnd, + losingSideDeadlinePassed, onSuccess, - disabled = false, + onLoadingChange, + isReconciling = false, + fundingPending = false, }) => { - const userChainId = useChainId(); - const { isConnected } = useAccount(); - const title = side === SideEnum.claimer ? "Claimer" : "Challenger"; - const shrunkAddress: string = - requester.substring(0, 6) + " ... " + requester.slice(-4); - const [requesterInput, setRequesterInput] = useState(""); + const title = side === SideEnum.claimer ? "Submitter" : "Challenger"; + const shrunkAddress = shortenAddress(partyAddress); const loading = useLoading(); const [isLoading, loadingMessage] = loading.use(); - const errorRef = useRef(false); - - const value = (formatEth(requesterFunds) * 100) / formatEth(appealCost); - const valueProgress = value > 100 ? 100 : value; - const unit = idToChain(chainId)?.nativeCurrency.symbol; - - const remainingAmount = - appealCost > requesterFunds ? appealCost - requesterFunds : 0n; - const inputAmount = useMemo(() => { - if (!requesterInput) return 0n; - try { - const parsed = parseEther(requesterInput); - return parsed < 0n ? 0n : parsed; - } catch { - return null; - } - }, [requesterInput]); + const errorToastShownRef = useRef(false); - const isInvalidInput = inputAmount === null; - const isZeroInput = inputAmount === 0n; - const exceedsRemaining = !isInvalidInput && inputAmount! > remainingAmount; - const funds = useEnoughFunds({ + const appealCostEth = formatEth(appealCost); + const fundedPercent = + appealCostEth > 0 ? (formatEth(partyFunds) * 100) / appealCostEth : 0; + const progressPercent = fundedPercent > 100 ? 100 : fundedPercent; + const isFullyFunded = appealCost > 0n && partyFunds >= appealCost; + const { + input: fundInput, + setInput: setFundInput, + inputAmount, + remainingAmount, + unit, + disabled: isDisabled, + tooltip: submitTooltip, + } = useFundingAmount({ chainId, - amount: !isInvalidInput && inputAmount ? inputAmount : undefined, + funded: partyFunds, + totalCost: appealCost, + defaultToRemaining: true, + checks: [ + { active: isFullyFunded, message: "Already funded" }, + { active: isReconciling, message: "Waiting for indexer" }, + { + active: losingSideDeadlinePassed, + message: "Appeal time has ended for this side", + }, + ], }); - - const { disabled: isDisabled, tooltip: submitTooltip } = resolveTxState([ - { active: disabled, message: "Syncing" }, - { - active: loosingSideHasEnd, - message: "Appeal time has ended for this side", - }, - { active: !isConnected, message: "Please connect your wallet" }, - { - active: userChainId !== chainId, - message: `Switch your chain above to ${idToChain(chainId)?.name || "the correct chain"}`, - }, - { active: !requesterInput, message: "Please enter an amount to fund" }, - { active: isInvalidInput, message: "Please enter a valid amount" }, - { active: isZeroInput, message: "Amount must be greater than 0" }, - { - active: exceedsRemaining, - message: `Amount exceeds remaining needed (${formatEth(remainingAmount)} ${unit})`, - }, - { active: funds.isLoading, message: "Checking balance" }, - { active: funds.insufficient, message: funds.message }, - ]); - const [prepareFundAppeal] = usePoHWrite( "fundAppeal", useMemo( @@ -121,92 +162,107 @@ const SideFunding: React.FC = ({ onReady(fire) { fire(); }, - onError() { + onError(error, errorCtx) { loading.stop(); - toast.error("Transaction rejected"); + onLoadingChange?.(false); + toast.error(getWriteErrorMessage(error, errorCtx)); }, - onSuccess() { + onSuccess(ctx) { loading.stop(); + onLoadingChange?.(false); toast.success("Funded appeal successfully"); - onSuccess?.(); + onSuccess?.(ctx.value ?? 0n, ctx.txHash); }, onFail() { loading.stop(); - !errorRef.current && + onLoadingChange?.(false); + // Only surface the failure toast once per mount. + !errorToastShownRef.current && toast.info( "Transaction is not possible! Do you have enough funds?", ); - errorRef.current = true; + errorToastShownRef.current = true; }, }), - [loading, onSuccess], + [loading, onLoadingChange, onSuccess], ), ); return ( -
-
- -
- {title} - {shrunkAddress} -
+
+
+ {title} + + {shrunkAddress} +
-
+
setRequesterInput(v.target.value)} - disabled={isLoading} + value={fundInput} + onChange={(v) => setFundInput(v.target.value)} + disabled={isLoading || isFullyFunded} /> { if (inputAmount === null || inputAmount === 0n) return; loading.start("Funding..."); + onLoadingChange?.(true); prepareFundAppeal({ - args: [arbitrator as Address, BigInt(disputeId), side], + args: [arbitrator, BigInt(disputeId), side], value: inputAmount, }); }} label={loadingMessage || "Fund"} - className="sm:w-auto" - disabled={isDisabled} + className="w-full px-5" + fullWidth + disabled={isDisabled || fundingPending} isLoading={isLoading} tooltip={submitTooltip} />
); }; interface AppealProps { - pohId: Address; - requestIndex: number; arbitrator: Address; extraData: any; claimer: Address; challenger: Address; disputeId: bigint; chainId: SupportedChainId; - currentChallenge: ArrayElement< - NonNullable["request"]>["challenges"] - >; - revocation: boolean; - requestStatus: RequestStatus; + currentChallenge: AppealChallenge; disabled?: boolean; tooltip?: string; + onAppealableChange?: (appealable: boolean) => void; +} + +// Everything read from the arbitrator/PoH contracts. Fetched once on mount — +// this is a snapshot, not live data. +interface AppealSnapshot { + status: DisputeStatusEnum; + /** End of the appeal period, in unix seconds. */ + appealPeriodEnd: number; + currentRulingSide: SideEnum; + /** + * Midpoint of the appeal period, in unix seconds. The contract only lets the + * currently losing side fund before this point; compared against a live clock + * so the gate closes while the page stays open. + */ + losingSideDeadline: number; + claimerCost: bigint; + challengerCost: bigint; } const Appeal: React.FC = ({ - pohId, - requestIndex, disputeId, arbitrator, extraData, @@ -214,318 +270,282 @@ const Appeal: React.FC = ({ claimer, challenger, currentChallenge, - revocation, - requestStatus, disabled: externalDisabled, tooltip: externalTooltip, + onAppealableChange, }) => { const { pendingAction } = useRequestOptimistic(); const isReconciling = pendingAction !== null; - const [totalClaimerCost, setTotalClaimerCost] = useState(0n); - const [totalChallengerCost, setTotalChallengerCost] = useState(0n); - const [formatedCurrentRuling, setFormatedCurrentRuling] = useState(""); - const defaultPeriod = [0n, 0n]; - const [period, setPeriod] = useState(defaultPeriod); - const [loosingSideHasEnd, setLoosingSideHasEnd] = useState(false); - const [loosingSideDeadline, setLoosingSideDeadline] = useState(0); - const [currentRulingFormatted, setCurrentRulingFormatted] = useState(0); - - const [disputeStatus, setDisputeStatus] = useState( - DisputeStatusEnum.Appealable, - ); - const [error, setError] = useState(false); - const errorRef = useRef(false); - const [loading, setLoading] = useState(true); - const [claimerFunds, setClaimerFunds] = useState(0n); - const [challengerFunds, setChallengerFunds] = useState(0n); const router = useRouter(); + const queryClient = useQueryClient(); + const appealSnapshotQueryKey = [ + "appealSnapshot", + chainId, + arbitrator, + disputeId.toString(), + ] as const; + const [isAppealModalOpen, setAppealModalOpen] = useState(false); + const [appealFundingPending, setAppealFundingPending] = useState(false); + const [fundSuccess, setFundSuccess] = useState<{ + amount: bigint; + txHash?: Hash; + } | null>(null); + + // Funds already committed to the pending appeal round, from the subgraph. + const { claimerFunds, challengerFunds } = useMemo( + () => getPendingAppealRoundFunds(currentChallenge), + [currentChallenge], + ); - const handleFundSuccess = () => { + const handleFundSuccess = (amount: bigint, txHash?: Hash) => { + setFundSuccess({ amount, txHash }); + }; + + const closeAppealModal = () => { setAppealModalOpen(false); - router.refresh(); + setFundSuccess(null); + if (fundSuccess) { + void queryClient.invalidateQueries({ queryKey: appealSnapshotQueryKey }); + router.refresh(); + } }; - useEffectOnce(() => { - const formatCurrentRuling = (currentRuling: SideEnum) => { - let text = "Undecided"; - switch (currentRuling) { - case SideEnum.claimer: - text = "Claimer wins"; - break; - case SideEnum.challenger: - text = "Challenger wins"; - break; - case SideEnum.shared: - text = "Shared"; - } - setFormatedCurrentRuling(text); - }; - - const calculateTotalCost = ( - appealCost: bigint, - currentRuling: SideEnum, - winnerMult: number, - loserMult: number, - sharedMult: number, - ) => { - const getSideTotalCost = (sideMultiplier: number) => { - return ( - Number(appealCost) + - (Number(appealCost) * sideMultiplier) / MULTIPLIER_DIVISOR + // bigint is not JSON-serializable, so the dispute id goes in as a string. + const { data: snapshot, isError: loadFailed } = useQuery({ + queryKey: appealSnapshotQueryKey, + queryFn: async (): Promise => { + const stakeMultipliers = await APIPoH.getStakeMultipliers(chainId); + const { status, cost, period, currentRuling } = + await APIArbitrator.getArbitratorsData( + chainId, + arbitrator, + disputeId, + extraData, ); + + const currentRulingSide = Number(currentRuling) as SideEnum; + const periodStart = Number(period![0]); + const periodEnd = Number(period![1]); + // Mirror the contract's integer division so the UI gate closes on the + // exact second the contract stops accepting the losing side's funds. + const losingSideDeadline = + periodStart + Math.floor((periodEnd - periodStart) / 2); + + return { + status: Number(status) as DisputeStatusEnum, + appealPeriodEnd: periodEnd, + currentRulingSide, + losingSideDeadline, + ...getSideAppealCosts(cost!, currentRulingSide, stakeMultipliers), }; - const MULTIPLIER_DIVISOR = 10000; - - const claimerMultiplier = - currentRuling === SideEnum.shared - ? sharedMult - : currentRuling === SideEnum.claimer - ? winnerMult - : loserMult; - const totalClaimerCost = getSideTotalCost(Number(claimerMultiplier)); - setTotalClaimerCost(BigInt(totalClaimerCost)); - - const challengerMultiplier = - currentRuling === SideEnum.shared - ? sharedMult - : currentRuling === SideEnum.claimer - ? loserMult - : winnerMult; - const totalChallengerCost = getSideTotalCost( - Number(challengerMultiplier), + }, + }); + + // Surface the load failure once. + useEffect(() => { + if (loadFailed) + toast.info( + "Unexpected error while reading appellate round info. Come back later", ); - setTotalChallengerCost(BigInt(totalChallengerCost)); - }; - - const getAppealData = async () => { - try { - const isPartiallyFunded = - Number(currentChallenge.nbRounds) + 1 === - currentChallenge.rounds.length; - const claimerFunds = isPartiallyFunded - ? currentChallenge.rounds.at(-1)?.requesterFund.amount - : 0n; - const challengerFunds = isPartiallyFunded - ? currentChallenge.rounds.at(-1)?.challengerFund - ? currentChallenge.rounds.at(-1)?.challengerFund?.amount - : 0n - : 0n; - setClaimerFunds(claimerFunds); - setChallengerFunds(challengerFunds); - - const stakeMultipliers: StakeMultipliers = - await APIPoH.getStakeMultipliers(chainId); - const winnerMult = stakeMultipliers.winnerStakeMultiplier; - const loserMult = stakeMultipliers.loserStakeMultiplier; - const sharedMult = stakeMultipliers.sharedStakeMultiplier; - - const arbitratorsData: ArbitratorsData = - await APIArbitrator.getArbitratorsData( - chainId, - arbitrator, - disputeId, - extraData, - ); - const status = arbitratorsData.status; - const cost = arbitratorsData.cost; - const period = arbitratorsData.period; - const currentRuling = arbitratorsData.currentRuling; - - setPeriod(period!); - const loosingSideDeadline = - (parseInt(String(period![0])) + parseInt(String(period![1]))) / 2; - - setLoosingSideHasEnd(loosingSideDeadline < Date.now() / 1000); - setLoosingSideDeadline(loosingSideDeadline); - setDisputeStatus(Number(status) as DisputeStatusEnum); - const currentRulingFormatted = Number(currentRuling) as SideEnum; - setCurrentRulingFormatted(currentRulingFormatted); - formatCurrentRuling(currentRulingFormatted); - calculateTotalCost( - cost!, - currentRulingFormatted, - Number(winnerMult), - Number(loserMult), - Number(sharedMult), - ); + }, [loadFailed]); - setLoading(false); - } catch (e) { - !errorRef.current && - toast.info( - "Unexpected error while reading appelate round info. Come back later", - ); - setError(true); - errorRef.current = true; - } - }; - getAppealData(); - }); + // Deadlines are snapshot timestamps; tick a live clock so the appealability + // and funding gates close when the midpoint / full period elapses while the + // page stays open. + const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000)); + useEffect(() => { + const id = setInterval( + () => setNowSec(Math.floor(Date.now() / 1000)), + 10_000, + ); + return () => clearInterval(id); + }, []); const appealTrigger = resolveTxState([ { active: !!externalDisabled, message: externalTooltip ?? "Disabled" }, - { active: isReconciling, message: "Syncing" }, + { active: isReconciling, message: "Waiting for indexer" }, ]); - return disputeStatus === DisputeStatusEnum.Appealable && - !error && - !loading ? ( + // Once the full period ends, no side can fund; before that only the losing + // side is cut off at the midpoint. + const appealEnded = !!snapshot && nowSec >= snapshot.appealPeriodEnd; + const isAppealable = + !loadFailed && + snapshot?.status === DisputeStatusEnum.Appealable && + !appealEnded; + + useEffect(() => { + onAppealableChange?.(isAppealable); + }, [isAppealable, onAppealableChange]); + + if (!isAppealable || !snapshot) return null; + + const { currentRulingSide } = snapshot; + const losingSideDeadlinePassed = nowSec >= snapshot.losingSideDeadline; + const rulingParty = + currentRulingSide === SideEnum.challenger + ? "Challenger" + : currentRulingSide === SideEnum.claimer + ? "Submitter" + : "neither side"; + + return ( <> -
- - {appealTrigger.disabled && ( - - {appealTrigger.tooltip} - - )} -
- + Appeal ends  + + + } + > +

+ When someone challenges a profile, a case is opened in Kleros Court. +

+

+ A group of random jurors is selected to review the case. They look at + the evidence from both sides and vote. The side with the most votes + wins the dispute. +

+

+ If either side disagrees with the decision, they can appeal. The case + is reviewed again by a new group of jurors. +

+

+ Providing clear evidence is important. It helps the jurors understand + the case and make a fair decision. +

+ + setAppealModalOpen(true)} + disabled={appealTrigger.disabled} + tooltip={appealTrigger.tooltip} + label="Appeal" + variant="secondary" + className="w-[170px]" + /> + setAppealModalOpen(false)} - className="max-h-[calc(100vh-2rem)] !w-[calc(100vw-2rem)] max-w-[1020px] overflow-y-auto md:!w-[88vw] xl:!w-[1020px]" + onClose={closeAppealModal} + canClose={!appealFundingPending} > -
-

- Appeal the decision: {formatedCurrentRuling} -

-
-
-
-
-
-
-
- - {!revocation ? ( - - The profile was challenged for{" "} - - {currentChallenge.reason.id} - - . - - ) : ( - - The profile was challenged. - - )} + {fundSuccess ? ( + <> + + Thanks for helping{" "} + fund the appeal! + + } + description="You contributed with" + /> +
+ } + />
-
- - -
- - Independent jurors evaluated the evidence, policy compliance, - and voted in favor of:{" "} - {currentRulingFormatted === SideEnum.challenger - ? "Challenger" - : currentRulingFormatted === SideEnum.claimer - ? "Claimer" - : "Shared"} - .{" "} - + {fundSuccess.txHash && idToChain(chainId) && ( +
- - Check how the jury voted - - + View the transaction +
+ )} + + + ) : ( + <> +
+ Case #{disputeId} +

+ Appeal the Decision +

+

+ The jury decided in favor of {rulingParty}. +

+ + Check how the jury voted + +
-
- - {loosingSideHasEnd ? ( - - The losing party's appeal time ended  - . +
+

+ Each side has its own crowdfunding target. A new appeal is + created only after both the submitter and challenger sides are + fully funded. If only one side reaches its target before the + deadline, the final ruling is set in favor of that funded side. +

+
+ + ⓘ - ) : ( - - The losing party's appeal time ends  - . - - )} -
-
- - - Appeal timeframe ends  - . - -
-
- - In order to appeal the decision, you need to fully fund the - crowdfunding deposit. The dispute will be sent to the jurors - when the full deposit is reached. Note that if the previous - round loser funds its side, the previous round winner should - also fully fund its side, in order not to lose the case. - -
- warning - + External contributors can also crowdfund the appeal.
-
-
- - + + +
+ -
-
- + + )} + - ) : null; + ); }; export default Appeal; diff --git a/src/app/[pohid]/[chain]/[request]/Challenge.tsx b/src/app/[pohid]/[chain]/[request]/Challenge.tsx index b3fb2411..e1edc4a9 100644 --- a/src/app/[pohid]/[chain]/[request]/Challenge.tsx +++ b/src/app/[pohid]/[chain]/[request]/Challenge.tsx @@ -1,8 +1,11 @@ import { useState, useMemo, useCallback } from "react"; import ALink from "components/ExternalLink"; -import Field from "components/Field"; -import Label from "components/Label"; -import Modal from "components/Modal"; +import EvidenceFormFields from "components/EvidenceFormFields"; +import RequestModal, { + RequestAmountPill, + RequestModalHeader, + RequestWarning, +} from "components/RequestModal"; import TimeAgo from "components/TimeAgo"; import { useLoading } from "hooks/useLoading"; import useEnoughFunds from "hooks/useEnoughFunds"; @@ -19,14 +22,17 @@ import { Hash } from "viem"; import DocumentIcon from "icons/NoteMajor.svg"; import { ObservablePrimitiveBaseFns } from "@legendapp/state"; import { ContractData } from "data/contract"; -import { useAtlasProvider, Roles } from "@kleros/kleros-app"; +import { useAtlasProvider } from "@kleros/kleros-app"; import { toast } from "react-toastify"; import AuthGuard from "components/AuthGuard"; import ActionButton from "components/ActionButton"; +import { CurrencyIcon } from "components/CurrencyField"; import { useChainId } from "wagmi"; -import { idToChain } from "config/chains"; +import { idToChain, nativeCurrencyLabel } from "config/chains"; import { getDisputedRequestStatus } from "utils/status"; import { useRequestOptimistic } from "optimistic/request"; +import { uploadEvidence } from "data/uploadEvidence"; +import { getWriteErrorMessage } from "hooks/useActionFeedback"; type Reason = | "none" @@ -35,30 +41,49 @@ type Reason = | "sybilAttack" | "deceased"; -const reasonToImage: Record = { - none: "", - incorrectSubmission: "/reason/incorrect.png", - identityTheft: "/reason/duplicate.png", - sybilAttack: "/reason/dne.png", - deceased: "/reason/deceased.png", +const reasonToImages: Partial> = { + incorrectSubmission: ["/reason/incorrect.png"], + identityTheft: ["/reason/duplicate.png"], + sybilAttack: ["/reason/dne.png"], + deceased: ["/reason/deceased.png"], }; -function reasonToIdx(reason: Reason) { - switch (reason) { - case "none": - return 0; - case "incorrectSubmission": - return 1; - case "identityTheft": - return 2; - case "sybilAttack": - return 3; - case "deceased": - return 4; - default: - return 0; - } -} +const REASON_INDEX: Record = { + none: 0, + incorrectSubmission: 1, + identityTheft: 2, + sybilAttack: 3, + deceased: 4, +}; + +const CHALLENGE_REASON_CARDS: { + reason: Reason; + label: string; + description: string; +}[] = [ + { + reason: "incorrectSubmission", + label: "Incorrect Submission", + description: "The profile does not follow the submission rules.", + }, + { + reason: "identityTheft", + label: "Identity Theft", + description: + "The submitter is trying to claim a Humanity ID that belongs to someone else.", + }, + { + reason: "sybilAttack", + label: "Sybil Attack", + description: + "The submitter is already registered, is a duplicate, or does not exist.", + }, + { + reason: "deceased", + label: "Deceased", + description: "The person previously existed but is no longer alive.", + }, +]; export const buildChallengeSuccessPatch = ( revocation: boolean, @@ -69,46 +94,76 @@ export const buildChallengeSuccessPatch = ( }); interface ReasonCardInterface { - text: string; + label: string; + description: string; reason: Reason; current: ObservablePrimitiveBaseFns; isUsed?: boolean; } const ReasonCard: React.FC = ({ - text, + label, + description, reason, current, isUsed = false, }) => { const isSelected = reason === current.get(); + const images = reasonToImages[reason]; return ( -
!isUsed && current.set(reason)} > -
- {reason} - {text} - {isUsed && ( - Already used + +
+ {images ? ( + images.map((image) => ( + 1 ? "w-1/2" : "w-full", + )} + alt="" + src={image} + /> + )) + ) : ( + + ID + )}
-
+ {label} + + {description} + + {isUsed && ( + + Already used + + )} + ); }; @@ -136,22 +191,28 @@ export default function Challenge({ const { uploadFile } = useAtlasProvider(); const { pendingAction, applyAction } = useRequestOptimistic(); const chain = useChainParam()!; + const unit = nativeCurrencyLabel(chain.id); const userChainId = useChainId(); const [isOpen, setIsOpen] = useState(false); const isReconciling = pendingAction !== null; + const defaultReason: Reason = "none"; const loading = useLoading(); const [isLoading, loadingMessage] = loading.use(); - const reason$ = useObservable("none"); + const reason$ = useObservable(defaultReason); const reason = reason$.use(); const [justification, setJustification] = useState(""); + const [file, setFile] = useState(null); + const [policyAccepted, setPolicyAccepted] = useState(false); const closeModal = useCallback(() => { setIsOpen(false); setJustification(""); - reason$.set("none"); + setFile(null); + setPolicyAccepted(false); + reason$.set(defaultReason); loading.stop(); - }, [loading, reason$]); + }, [defaultReason, loading, reason$]); const [prepare] = usePoHWrite( "challengeRequest", @@ -167,9 +228,9 @@ export default function Challenge({ loading.stop(); toast.error("Transaction failed"); }, - onError() { + onError(error, errorCtx) { loading.stop(); - toast.error("Transaction rejected"); + toast.error(getWriteErrorMessage(error, errorCtx)); }, onSuccess() { applyAction("challenge", buildChallengeSuccessPatch(revocation)); @@ -182,30 +243,20 @@ export default function Challenge({ ); const submit = useCallback(async () => { - if (revocation === !reason && !justification) return; + if ( + !justification.trim() || + !policyAccepted || + (!revocation && (reason === "none" || usedReasons.includes(reason))) + ) + return; loading.start("Uploading evidence..."); try { - const evidenceJson = { + const { evidenceUri } = await uploadEvidence(uploadFile, { name: "Challenge Justification", description: justification, - }; - - const evidenceTextFile = new File( - [JSON.stringify(evidenceJson)], - "evidence", - { - type: "text/plain", - }, - ); - - const evidenceUri = await uploadFile(evidenceTextFile, Roles.Evidence); - - if (!evidenceUri) { - toast.error("Failed to upload evidence."); - loading.stop(); - return; - } + file, + }); loading.start("Challenging..."); prepare({ @@ -213,13 +264,13 @@ export default function Challenge({ args: [ pohId, BigInt(requestIndex), - reasonToIdx(revocation ? "none" : reason), + REASON_INDEX[revocation ? "none" : reason], evidenceUri, ], }); } catch (error) { toast.error( - `Failed to upload evidence : ${error instanceof Error ? error.message : "Unknown error"}`, + error instanceof Error ? error.message : "Failed to upload evidence.", ); loading.stop(); } @@ -227,6 +278,9 @@ export default function Challenge({ revocation, reason, justification, + file, + policyAccepted, + usedReasons, prepare, arbitrationCost, pohId, @@ -239,33 +293,26 @@ export default function Challenge({ return usedReasons.includes(reason); }; - // Define reason cards data - const reasonCards = [ - { reason: "incorrectSubmission" as Reason, text: "Incorrect Submission" }, - { reason: "identityTheft" as Reason, text: "Identity Theft" }, - { reason: "sybilAttack" as Reason, text: "Sybil Attack" }, - { reason: "deceased" as Reason, text: "Deceased" }, - ]; - const funds = useEnoughFunds({ chainId: chain.id, amount: arbitrationCost }); const { disabled: submitDisabled, tooltip: submitTooltip } = resolveTxState([ - { active: isReconciling, message: "Syncing" }, + { active: isReconciling, message: "Waiting for indexer" }, { active: userChainId !== chain.id, message: `Switch your chain above to ${idToChain(chain.id)?.name || "the correct chain"}`, }, - { active: !justification, message: "Enter a justification" }, + { active: !justification.trim(), message: "Enter a justification" }, { active: !revocation && reason === "none", message: "Select a challenging reason", }, { active: funds.isLoading, message: "Checking balance" }, + { active: !policyAccepted, message: "Confirm that you read the Policy" }, { active: funds.insufficient, message: funds.message }, ]); const trigger = resolveTxState([ { active: !!externalDisabled, message: externalTooltip }, - { active: isReconciling, message: "Syncing" }, + { active: isReconciling, message: "Waiting for indexer" }, { active: userChainId !== chain.id, message: `Switch your chain above to ${idToChain(chain.id)?.name || "the correct chain"}`, @@ -280,67 +327,107 @@ export default function Challenge({ disabled={trigger.disabled} tooltip={trigger.tooltip} /> - -
- - - - Registration Policy - - - (at the time of submission) - - - - Updated: - - - {!revocation && ( + + - -
- {reasonCards.map((card) => ( - - ))} -
+ Challenge this Profile - )} - - setJustification(e.target.value)} + } + description="In order to challenge this profile you need to deposit:" + /> +
+ } /> +
-
- Deposit: {formatEth(arbitrationCost)} {chain.nativeCurrency.symbol} + {!revocation && ( +
+

+ Select the challenge type +

+
+ {CHALLENGE_REASON_CARDS.map((card) => ( + + ))} +
+ )} - + + + + When someone challenges a profile, a case is opened in Kleros Court. A + group of random jurors is selected to review the case. They look at + the evidence from both sides and vote. The side with the most votes + wins the dispute. Deposits, reimbursements, and rewards are + distributed according to the final ruling and the contract rules. A + losing challenger can lose their deposit. Before challenging, make + sure you have read and understood the Policy below. + + +
+ + Relevant Policy + + + (updated ) + + + +
+ +
+ +
- + ); } diff --git a/src/app/[pohid]/[chain]/[request]/Evidence.tsx b/src/app/[pohid]/[chain]/[request]/Evidence.tsx index 25391f3c..f9af7924 100644 --- a/src/app/[pohid]/[chain]/[request]/Evidence.tsx +++ b/src/app/[pohid]/[chain]/[request]/Evidence.tsx @@ -1,90 +1,39 @@ "use client"; -import { enableReactUse } from "@legendapp/state/config/enableReactUse"; -import { useObservable } from "@legendapp/state/react"; import Accordion from "components/Accordion"; +import ActionButton from "components/ActionButton"; +import AddEvidenceModal from "components/AddEvidenceModal"; import Attachment from "components/Attachment"; import ExternalLink from "components/ExternalLink"; -import Field from "components/Field"; +import ExternalLinkIcon from "components/ExternalLinkIcon"; import Identicon from "components/Identicon"; -import Label from "components/Label"; -import Modal from "components/Modal"; import TimeAgo from "components/TimeAgo"; -import FileUploadZone from "components/FileUploadZone"; import { explorerLink, idToChain } from "config/chains"; -import { Effects } from "contracts/hooks/types"; -import usePoHWrite from "contracts/hooks/usePoHWrite"; -import { RequestQuery } from "generated/graphql"; +import type { EvidenceSubmitterProfile } from "data/evidence"; import useChainParam from "hooks/useChainParam"; import useIPFS from "hooks/useIPFS"; -import { useLoading } from "hooks/useLoading"; -import DocumentIcon from "icons/NoteMajor.svg"; -import type { - OptimisticEvidenceItem, - RequestOptimisticOverlay, -} from "optimistic/types"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { toast } from "react-toastify"; -import useSWR from "swr"; -import ActionButton from "components/ActionButton"; -import { EvidenceFile, MetaEvidenceFile } from "types/docs"; +import Image from "next/image"; +import Link from "next/link"; +import type { OptimisticEvidenceItem } from "optimistic/types"; +import { useRequestOptimistic } from "optimistic/request"; +import { useEffect, useState } from "react"; +import { EvidenceFile } from "types/docs"; import { shortenAddress } from "utils/address"; -import { ipfsFetch, ipfs } from "utils/ipfs"; -import { romanize } from "utils/misc"; +import { prettifyId } from "utils/identifier"; +import { safeIpfsUrl } from "utils/ipfs"; import { resolveTxState } from "utils/txState"; -import { Address, Hash } from "viem"; -import { useAccount, useChainId } from "wagmi"; -import { useAtlasProvider, Roles } from "@kleros/kleros-app"; -import AuthGuard from "components/AuthGuard"; -import { useRequestOptimistic } from "optimistic/request"; - -enableReactUse(); - -export const buildEvidenceSuccessItem = ( - uri: string, - submitter: Address, - name: string, - description: string, - fileURI?: string, - txHash?: string, -): OptimisticEvidenceItem => ({ - id: `optimistic-evidence-${txHash ?? Date.now()}`, - uri, - creationTime: Math.floor(Date.now() / 1000), - submitter, - name, - description, - fileURI, -}); - -export const buildEvidenceSuccessPatch = ( - uri: string, - submitter: Address, - name: string, - description: string, - fileURI?: string, - txHash?: string, -): RequestOptimisticOverlay => ({ - evidenceList: [ - buildEvidenceSuccessItem( - uri, - submitter, - name, - description, - fileURI, - txHash, - ), - ], -}); +import { Hash } from "viem"; +import { useChainId } from "wagmi"; interface ItemInterface { - index: number; + number: number; item: OptimisticEvidenceItem; isPending?: boolean; + profile?: EvidenceSubmitterProfile; } -function Item({ index, item, isPending }: ItemInterface) { - const chain = useChainParam()!; +function Item({ number, item, isPending, profile }: ItemInterface) { + const chain = useChainParam(); const [evidence] = useIPFS(item.uri); const ipfsUri = evidence?.fileURI ? evidence?.fileURI @@ -94,40 +43,68 @@ function Item({ index, item, isPending }: ItemInterface) { const title = evidence?.name || item.name; const description = evidence?.description || item.description; + if (!chain) return null; + + const shortAddress = shortenAddress(item.submitter); + const photoUrl = safeIpfsUrl(profile?.photo); + return (
-
- - {romanize(index + 1)} - - {isPending && ( - - Pending - - )} -
- {title} - {ipfsUri && } +
+
+
+ + #{number} - {title} + + {isPending && ( + + Pending + + )} + {ipfsUri && } +
+

+ {description} +

-

{description}

-
-
- -
- - submitted by{" "} +
+ {photoUrl ? ( + profile + ) : ( + + )} + {profile ? ( + + {profile.name || shortAddress} + + + ) : ( - {shortenAddress(item.submitter)} + {shortAddress} + )} + + -
@@ -137,169 +114,101 @@ function Item({ index, item, isPending }: ItemInterface) { interface EvidenceProps { pohId: Hash; requestIndex: number; - arbitrationInfo: NonNullable["arbitratorHistory"]; + submitterProfiles: Record; } export default function Evidence({ pohId, requestIndex, - arbitrationInfo, + submitterProfiles, }: EvidenceProps) { - const { effective, pendingAction, pendingEvidenceItem, applyAction } = + const { effective, pendingAction, pendingEvidenceItem } = useRequestOptimistic(); const isReconciling = pendingAction !== null; - const chainReq = useChainParam()!; + const chainReq = useChainParam(); const chainId = useChainId(); - const { address } = useAccount(); - const { data: policy } = useSWR( - arbitrationInfo.registrationMeta, - async (metaEvidenceLink) => - (await ipfsFetch(metaEvidenceLink)).fileURI, - ); const [modalOpen, setModalOpen] = useState(false); - const loading = useLoading(); - const [pending, loadingMessage] = loading.use(); - const state$ = useObservable({ - uri: "", - name: "", - description: "", - fileURI: "", - }); - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); - const [file, setFile] = useState(null); - const closeModal = useCallback(() => { - setModalOpen(false); - setTitle(""); - setDescription(""); - setFile(null); - state$.uri.set(""); - state$.name.set(""); - state$.description.set(""); - state$.fileURI.set(""); - loading.stop(); - }, [loading, state$]); - - const { uploadFile } = useAtlasProvider(); - const [prepare] = usePoHWrite( - "submitEvidence", - useMemo( - () => ({ - onReady(fire) { - fire(); - loading.start("Transaction pending"); - toast.info("Transaction pending"); - }, - onFail() { - state$.uri.set(""); - state$.name.set(""); - state$.description.set(""); - state$.fileURI.set(""); - loading.stop(); - toast.error("Transaction failed"); - }, - onError() { - state$.uri.set(""); - state$.name.set(""); - state$.description.set(""); - state$.fileURI.set(""); - loading.stop(); - toast.error("Transaction rejected"); - }, - onSuccess(ctx) { - const uri = - typeof ctx.args?.[2] === "string" ? ctx.args[2] : undefined; - if (address && uri) { - applyAction( - "evidence", - buildEvidenceSuccessPatch( - uri, - address, - state$.name.get(), - state$.description.get(), - state$.fileURI.get() || undefined, - ctx.txHash, - ), - ); - } - toast.success("Evidence submitted successfully"); - closeModal(); - }, - }), - [ - address, - applyAction, - closeModal, - loading, - state$.description, - state$.fileURI, - state$.name, - state$.uri, - ], - ), + const [evidenceOpen, setEvidenceOpen] = useState( + effective.status === "disputed", + ); + const [firstEvidenceVisible, setFirstEvidenceVisible] = useState(true); + const [lastEvidenceVisible, setLastEvidenceVisible] = useState(true); + // Track the observed elements via state (not refs) so the observer effect + // re-runs whenever the first/last item actually (re-)mounts — important + // because the accordion unmounts and re-mounts its children on close/open. + const [firstEvidenceEl, setFirstEvidenceEl] = useState( + null, + ); + const [lastEvidenceEl, setLastEvidenceEl] = useState( + null, ); - const submit = async () => { - state$.uri.set(""); - loading.start("Uploading evidence..."); - - let evidenceFileURI; - try { - if (file) { - evidenceFileURI = await uploadFile(file, Roles.Evidence); - if (!evidenceFileURI) { - toast.error("Failed to upload file."); - loading.stop(); - return; - } - } - - const evidenceJson = { - name: title, - description, - fileURI: evidenceFileURI, - }; - - const evidenceTextFile = new File( - [JSON.stringify(evidenceJson)], - "evidence", - { - type: "text/plain", - }, - ); + const confirmed = effective.evidenceList; + const confirmedCount = confirmed.length; + const hasPending = pendingAction === "evidence" && !!pendingEvidenceItem; + const evidenceItems: Array<{ + item: OptimisticEvidenceItem; + isPending: boolean; + number: number; + }> = [ + ...(hasPending + ? [ + { + item: pendingEvidenceItem, + isPending: true, + number: confirmedCount + 1, + }, + ] + : []), + ...confirmed.map((item, index) => ({ + item, + isPending: false, + number: confirmedCount - index, + })), + ]; + useEffect(() => { + if (!evidenceOpen || !firstEvidenceEl || !lastEvidenceEl) return; - const evidenceUri = await uploadFile(evidenceTextFile, Roles.Evidence); + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.target === firstEvidenceEl) + setFirstEvidenceVisible(entry.isIntersecting); + if (entry.target === lastEvidenceEl) + setLastEvidenceVisible(entry.isIntersecting); + }); + }, + { threshold: 0.01 }, + ); - if (!evidenceUri) { - toast.error("Failed to upload evidence."); - loading.stop(); - return; - } + observer.observe(firstEvidenceEl); + if (lastEvidenceEl !== firstEvidenceEl) observer.observe(lastEvidenceEl); - state$.uri.set(evidenceUri); - state$.name.set(title); - state$.description.set(description); - state$.fileURI.set(evidenceFileURI || ""); - } catch (error) { - toast.error(`Failed to upload evidence : - ${error instanceof Error ? error.message : "Unknown error"}`); - loading.stop(); - } - }; + return () => observer.disconnect(); + }, [evidenceOpen, firstEvidenceEl, lastEvidenceEl]); + // Hide the "Scroll to..." shortcuts while the open animation is running: + // the accordion height transition (550ms, Accordion's ANIMATION_MS) plus the + // staggered item entries (delay capped at 800ms + 600ms accordionItemIn). + // Jumping to an anchor mid-animation would land on a moving target. + const [isAnimating, setIsAnimating] = useState(false); useEffect(() => { - const unsubscribe = state$.onChange(({ value }) => { - if (!value.uri) return; - prepare({ args: [pohId, BigInt(requestIndex), value.uri] }); - }); + if (!evidenceOpen) return; - return () => unsubscribe(); - }, [pohId, prepare, requestIndex, state$]); + setIsAnimating(true); + const timeout = window.setTimeout( + () => setIsAnimating(false), + 550 + Math.min(evidenceItems.length, 8) * 100 + 600, + ); + return () => window.clearTimeout(timeout); + }, [evidenceOpen, evidenceItems.length]); + + if (!chainReq) return null; const isEvidenceDisabled = chainReq.id !== chainId; const evidenceTrigger = resolveTxState([ - { active: isReconciling, message: "Syncing" }, + { active: isReconciling, message: "Waiting for indexer" }, { active: isEvidenceDisabled, message: `Switch your chain above to ${idToChain(chainReq.id)?.name || "the correct chain"}`, @@ -307,94 +216,86 @@ export default function Evidence({ ]); return ( - + setEvidenceOpen((open) => !open)} + title="Evidence" + size="lg" + unmountOnClose + > {requestIndex >= 0 && ( <> -
+
setModalOpen(true)} label="Add Evidence" tooltip={evidenceTrigger.tooltip} + className="w-[min(100%,10.625rem)]" /> + {evidenceItems.length > 1 && + !lastEvidenceVisible && + !isAnimating && ( + + Scroll to oldest evidence ↓ + + )}
- -
- {policy && ( -
- - - - Registration Policy - - (at the time of submission) - - - Updated: - -
- )} - - setTitle(e.target.value)} - /> - setDescription(e.target.value)} - /> - - { - const acceptedFile = acceptedFiles[0]; - if (acceptedFile) setFile(acceptedFile); - }} - /> - - - -
-
+ onClose={() => setModalOpen(false)} + pohId={pohId} + requestIndex={requestIndex} + chainId={chainReq.id} + /> )} - {pendingAction === "evidence" && pendingEvidenceItem && ( - - )} - {effective.evidenceList.map((item, i) => ( - - ))} + {evidenceItems.map(({ item, isPending, number }, index) => { + const isFirst = index === 0; + const isLast = index === evidenceItems.length - 1; + + return ( +
+ +
+ ); + })} + + {requestIndex >= 0 && + evidenceItems.length > 1 && + !firstEvidenceVisible && + !isAnimating && ( + + )} ); } diff --git a/src/app/[pohid]/[chain]/[request]/Funding.tsx b/src/app/[pohid]/[chain]/[request]/Funding.tsx index 6f68cfc6..541cba60 100644 --- a/src/app/[pohid]/[chain]/[request]/Funding.tsx +++ b/src/app/[pohid]/[chain]/[request]/Funding.tsx @@ -1,19 +1,23 @@ import { useCallback, useMemo, useState } from "react"; import { toast } from "react-toastify"; import CurrencyField from "components/CurrencyField"; -import Modal from "components/Modal"; import ActionButton from "components/ActionButton"; +import Progress from "components/Progress"; +import RequestModal, { + RequestModalActions, + RequestModalHeader, +} from "components/RequestModal"; import usePoHWrite from "contracts/hooks/usePoHWrite"; import { useLoading } from "hooks/useLoading"; -import useEnoughFunds from "hooks/useEnoughFunds"; +import useFundingAmount from "hooks/useFundingAmount"; import { resolveTxState } from "utils/txState"; import type { RequestOptimisticOverlay } from "optimistic/types"; -import { Hash, formatEther, parseEther } from "viem"; +import { Hash, formatEther } from "viem"; import useChainParam from "hooks/useChainParam"; -import { useAccount, useChainId } from "wagmi"; import { formatEth } from "utils/misc"; import { idToChain } from "config/chains"; import { useRequestOptimistic } from "optimistic/request"; +import { getWriteErrorMessage } from "hooks/useActionFeedback"; export const buildFundSuccessPatch = ( funded: bigint, @@ -42,17 +46,35 @@ const FundButton: React.FC = ({ }) => { const { effective, pendingAction, applyAction } = useRequestOptimistic(); const chain = useChainParam()!; - const userChainId = useChainId(); - const [addedFundInput, setAddedFundInput] = useState(""); const [isModalOpen, setIsModalOpen] = useState(false); - const { isConnected } = useAccount(); const loading = useLoading(); const [isLoading, loadingMessage] = loading.use(); + const isReconciling = pendingAction !== null; + const { + input: addedFundInput, + setInput: setAddedFundInput, + resetInput, + inputAmount, + remainingAmount, + unit, + isWrongChain, + disabled: isDisabled, + tooltip: submitTooltip, + } = useFundingAmount({ + chainId: chain.id, + funded, + totalCost, + defaultToRemaining: true, + checks: [ + { active: isReconciling, message: "Waiting for indexer" }, + { active: isLoading }, + ], + }); const closeModal = useCallback(() => { setIsModalOpen(false); - setAddedFundInput(""); + resetInput(); loading.stop(); - }, [loading]); + }, [loading, resetInput]); const [prepareFund] = usePoHWrite( "fundRequest", @@ -66,9 +88,9 @@ const FundButton: React.FC = ({ loading.stop(); toast.error("Transaction failed"); }, - onError() { + onError(error, errorCtx) { loading.stop(); - toast.error("Transaction rejected"); + toast.error(getWriteErrorMessage(error, errorCtx)); }, onSuccess(ctx) { applyAction( @@ -87,27 +109,12 @@ const FundButton: React.FC = ({ ), ); - const remainingAmount = totalCost - funded; const maxFundAmount = formatEther(remainingAmount); - - const inputAmount = useMemo(() => { - if (!addedFundInput) return 0n; - try { - const parsed = parseEther(addedFundInput); - return parsed < 0n ? 0n : parsed; - } catch { - return null; - } - }, [addedFundInput]); - - const isInvalidInput = inputAmount === null; - const isNonPositive = !isInvalidInput && inputAmount! <= 0n; - const exceedsRemaining = !isInvalidInput && inputAmount! > remainingAmount; - const funds = useEnoughFunds({ - chainId: chain.id, - amount: !isInvalidInput && inputAmount ? inputAmount : undefined, - }); - const isReconciling = pendingAction !== null; + const totalCostEth = formatEth(totalCost); + const progress = + totalCostEth > 0 + ? Math.min(100, (formatEth(funded) * 100) / totalCostEth) + : 0; const handleSubmit = () => { if (inputAmount === null || inputAmount <= 0n) return; @@ -119,30 +126,11 @@ const FundButton: React.FC = ({ }); }; - const { disabled: isDisabled, tooltip: submitTooltip } = resolveTxState([ - { active: isReconciling, message: "Syncing" }, - { active: !isConnected, message: "Please connect your wallet" }, - { - active: userChainId !== chain.id, - message: `Switch your chain above to ${idToChain(chain.id)?.name || "the correct chain"}`, - }, - { active: !addedFundInput, message: "Please enter an amount to fund" }, - { active: isInvalidInput, message: "Please enter a valid amount" }, - { active: isNonPositive, message: "Amount must be greater than 0" }, - { - active: exceedsRemaining, - message: `Amount exceeds remaining needed (${formatEth(remainingAmount)} ${chain.nativeCurrency.symbol})`, - }, - { active: funds.isLoading, message: "Checking balance" }, - { active: funds.insufficient, message: funds.message }, - { active: isLoading }, - ]); - const trigger = resolveTxState([ { active: !!externalDisabled, message: externalTooltip }, - { active: isReconciling, message: "Syncing" }, + { active: isReconciling, message: "Waiting for indexer" }, { - active: userChainId !== chain.id, + active: isWrongChain, message: `Switch your chain above to ${idToChain(chain.id)?.name || "the correct chain"}`, }, ]); @@ -150,36 +138,47 @@ const FundButton: React.FC = ({ return ( <> setIsModalOpen(true)} + onClick={() => { + setAddedFundInput(maxFundAmount); + setIsModalOpen(true); + }} label="Fund" + variant="secondary" className="mb-2 w-auto" disabled={trigger.disabled} tooltip={trigger.tooltip} /> - -
-
- - setAddedFundInput(formatEth(remainingAmount).toString()) - } - className="text-orange mx-1 cursor-pointer font-semibold underline underline-offset-2" - > - {maxFundAmount} - {" "} - - {chain.nativeCurrency.symbol} Needed - -
+ + Fund{" "} + the profile's submission + + } + description={ + <> +

Anyone can help funding the profile's submission.

+

Type the value you want to contribute below.

+ + } + /> +
+ +
+
= ({ onChange={(e) => setAddedFundInput(e.target.value)} disabled={isLoading} /> -
- -
- + + ); }; diff --git a/src/app/[pohid]/[chain]/[request]/Info.tsx b/src/app/[pohid]/[chain]/[request]/Info.tsx index f178c8eb..0084ba6c 100644 --- a/src/app/[pohid]/[chain]/[request]/Info.tsx +++ b/src/app/[pohid]/[chain]/[request]/Info.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState } from "react"; -import Modal from "components/Modal"; +import ActionButton from "components/ActionButton"; +import RequestModal, { RequestModalHeader } from "components/RequestModal"; import InfoIcon from "icons/info.svg"; -import Image from "next/image"; +import { useState } from "react"; interface InfoProps { nbRequests: number; @@ -11,39 +11,51 @@ interface InfoProps { } export default function Info({ nbRequests, label }: InfoProps) { - const [isOpen, setIsOpen] = useState(false); + const [open, setOpen] = useState(false); return ( <> - setIsOpen(true)} - > - {label}  - - - setIsOpen(false)} + + setOpen(false)}> + + What is a PoH ID? + + } + description={ +
+

+ A PoH ID is a unique, non-transferable identity for a verified + human on Proof of Humanity. +

+

+ For a first registration, it is normally derived from the + registering wallet address. The identifier stays attached to the + human and can be reclaimed from a different wallet when the + protocol's recovery conditions are met. +

+

+ This POH ID had {nbRequests} requests claimed in this chain. +

+
+ } /> -

- The Proof of Humanity ID is a soulbound ID. It corresponds to each - unique human registered on Proof of Humanity. -

-

- This POH ID had {nbRequests} requests claimed in this - chain -

-
+
+ setOpen(false)} + className="w-full sm:w-[170px]" + /> +
+ ); } diff --git a/src/app/[pohid]/[chain]/[request]/RemoveVouch.tsx b/src/app/[pohid]/[chain]/[request]/RemoveVouch.tsx index f8545bd9..bef6f4c0 100644 --- a/src/app/[pohid]/[chain]/[request]/RemoveVouch.tsx +++ b/src/app/[pohid]/[chain]/[request]/RemoveVouch.tsx @@ -11,6 +11,7 @@ import { useRequestOptimistic } from "optimistic/request"; import type { RequestOptimisticOverlay } from "optimistic/types"; import { useAccount } from "wagmi"; import { resolveTxState } from "utils/txState"; +import { getWriteErrorMessage } from "hooks/useActionFeedback"; enableReactUse(); @@ -62,8 +63,8 @@ export default function RemoveVouch({ "removeVouch", useMemo( () => ({ - onError() { - toast.error("Transaction rejected"); + onError(error, errorCtx) { + toast.error(getWriteErrorMessage(error, errorCtx)); }, onLoading() { loading.start(); @@ -96,29 +97,33 @@ export default function RemoveVouch({ prepareRemoveVouch({ args: [requester, pohId] }); }); + // Covers both the wallet-confirmation and tx-mining phases, matching the + // other action buttons; `status.write` alone re-enables while still mining. + const isRemoveVouchLoading = + status.write === "pending" || + (status.write === "success" && status.transaction === "pending"); + const trigger = resolveTxState([ - { active: isReconciling, message: "Syncing" }, + { active: isReconciling, message: "Waiting for indexer" }, { active: !!disabled, message: tooltip }, { active: userChainId !== chain.id, message: `Switch your chain above to ${idToChain(chain.id)?.name || "the correct chain"}`, }, - { active: status.write === "pending" }, + { active: isRemoveVouchLoading }, ]); return ( web3Loaded && (userChainId === chain.id || disabled) && ( -
- -
+ ) ); } diff --git a/src/app/[pohid]/[chain]/[request]/RequestErrorState.tsx b/src/app/[pohid]/[chain]/[request]/RequestErrorState.tsx index cdd5b5e5..d893f218 100644 --- a/src/app/[pohid]/[chain]/[request]/RequestErrorState.tsx +++ b/src/app/[pohid]/[chain]/[request]/RequestErrorState.tsx @@ -23,7 +23,7 @@ export function ChainDataUnavailableCard({ chainName }: { chainName: string }) { elsewhere in the app may also be incomplete or out of date. Nothing on-chain is affected — please try again in a few minutes.
- + ); } diff --git a/src/app/[pohid]/[chain]/[request]/RequestEvidenceSection.tsx b/src/app/[pohid]/[chain]/[request]/RequestEvidenceSection.tsx index fb1ff1dd..60428ff8 100644 --- a/src/app/[pohid]/[chain]/[request]/RequestEvidenceSection.tsx +++ b/src/app/[pohid]/[chain]/[request]/RequestEvidenceSection.tsx @@ -1,3 +1,5 @@ +import { Suspense } from "react"; +import { getEvidenceSubmitterProfiles } from "data/evidence"; import { RequestOptimisticProvider } from "optimistic/request"; import type { RequestOptimisticBase } from "optimistic/types"; import type { Address } from "viem"; @@ -7,20 +9,50 @@ import type { RequestPageRequest, } from "./RequestIdentityCard.types"; +function EvidenceSectionSkeleton() { + return ( +
+ ); +} + +/** + * @notice Resolves the submitter → profile map, then renders the evidence list. + * @dev Split out so the (potentially slow) cross-chain profile lookup suspends + * behind a skeleton instead of blocking the whole evidence section from + * painting. Profiles only decide internal-profile vs explorer links per item. + */ +async function EvidenceWithProfiles({ + pohId, + requestIndex, + submitters, +}: { + pohId: `0x${string}`; + requestIndex: number; + submitters: Address[]; +}) { + const submitterProfiles = await getEvidenceSubmitterProfiles(submitters); + + return ( + + ); +} + /** * @notice Resolves and renders the request evidence section. * @dev The caller chooses whether this is current-request evidence or * historical identity evidence. The nested optimistic provider only overrides * `evidenceList` and reuses the parent optimistic state. */ -export default async function RequestEvidenceSection({ - arbitrationInfo, +export default function RequestEvidenceSection({ evidenceSource, optimisticBase, pohId, request, }: { - arbitrationInfo: RequestPageRequest["arbitratorHistory"]; evidenceSource: RequestEvidenceSource; optimisticBase: RequestOptimisticBase; pohId: `0x${string}`; @@ -45,11 +77,13 @@ export default async function RequestEvidenceSection({ evidenceList, }} > - + }> + item.submitter)} + /> + ); } diff --git a/src/app/[pohid]/[chain]/[request]/RequestIdentityCard.tsx b/src/app/[pohid]/[chain]/[request]/RequestIdentityCard.tsx index fbc227e8..d4d80b95 100644 --- a/src/app/[pohid]/[chain]/[request]/RequestIdentityCard.tsx +++ b/src/app/[pohid]/[chain]/[request]/RequestIdentityCard.tsx @@ -1,10 +1,11 @@ -import Arrow from "components/Arrow"; import Attachment from "components/Attachment"; -import ChainLogo from "components/ChainLogo"; import DocumentIcon from "components/DocumentIcon"; import ExternalLink from "components/ExternalLink"; import Identicon from "components/Identicon"; +import IdentityReferenceRow from "components/IdentityReferenceRow"; import Label from "components/Label"; +import LoadableImage from "components/LoadableImage"; +import MediaFallback from "components/MediaFallback"; import Previewed from "components/Previewed"; import TimeAgo from "components/TimeAgo"; import VideoThumbnail from "components/VideoThumbnail"; @@ -108,7 +109,7 @@ export async function RevocationBanner({ Requested by {request.requester} @@ -142,13 +143,11 @@ function ProfileSummary({ } /> @@ -168,28 +167,35 @@ export async function DesktopProfileAside({ identity, identityFiles, request, + vouchedFor, + vouchers, }: { identity: RequestIdentitySource; identityFiles: RequestIdentityFiles; request: RequestPageRequest; + vouchedFor: ReactNode; + vouchers: ReactNode; }) { const registrationFile = await identityFiles.registrationFilePromise; const displayedClaimerName = registrationFile?.name || identity.claimer.name || ""; return ( -
-
+
+
- -
); @@ -202,32 +208,32 @@ export async function DesktopProfileAside({ export async function IdentityHeader({ chain, identity, + pohId, }: { chain: RequestChain; identity: RequestIdentitySource; + pohId: `0x${string}`; }) { const displayedClaimerId = identity.claimer.id as Address; + const prettyPohId = prettifyId(pohId); return ( -
-
- - - {displayedClaimerId.slice(0, 20)} - - {displayedClaimerId.slice(20)} - -
- - - {chain.name} - +
+ + + + + POH ID +
); } @@ -237,11 +243,7 @@ export async function IdentityHeader({ * @dev Returns null when the meta-evidence file is unavailable or has no file * URI, allowing the rest of the identity card to stream without this request. */ -export async function PolicyLink({ - metaEvidenceUri, -}: { - metaEvidenceUri: string; -}) { +async function PolicyLink({ metaEvidenceUri }: { metaEvidenceUri: string }) { try { const policyLink = (await ipfsFetch(metaEvidenceUri)) .fileURI; @@ -250,15 +252,13 @@ export async function PolicyLink({ const href = `/attachment?url=${encodeURIComponent(policyLink)}`; return ( -
+
- -
- Relevant Policy -
+ Relevant Policy +
); @@ -268,60 +268,20 @@ export async function PolicyLink({ } /** - * @notice Renders the policy link and vouch slots below the registration video. - * @dev Keeps caller-provided vouch UI composed into the identity card. + * @notice Waits for identity data and renders the registration video. + * @dev The same media block is reused across responsive layouts. */ -export function RequestRelatedActions({ - policyMetaEvidenceUri, - vouchedFor, - vouchers, -}: { - policyMetaEvidenceUri: string; - vouchedFor: ReactNode; - vouchers: ReactNode; -}) { - return ( - <> -
- - - - {vouchedFor} -
-
- {vouchers} -
- - ); -} - -/** - * @notice Waits for identity data and renders mobile profile media and video. - * @dev Desktop profile media is handled by `DesktopProfileAside`. - */ -export async function MobileIdentityMedia({ - identity, +export async function IdentityVideo({ identityFiles, }: { - identity: RequestIdentitySource; identityFiles: RequestIdentityFiles; }) { const registrationFile = await identityFiles.registrationFilePromise; - const displayedClaimerName = - registrationFile?.name || identity.claimer.name || ""; const videoUrl = safeIpfsUrl(registrationFile?.video); return ( - <> -
- -
- {registrationFile && videoUrl && ( +
+ {videoUrl ? ( <> } /> - + Tap video to preview fullscreen + ) : ( + )} - +
); } @@ -363,10 +329,8 @@ export default function RequestIdentityCard({ identity, request, }); - const prettyPohId = prettifyId(pohId); - return ( -
+
+
+
+ +
+
} >
-
- }> - +
+ } + > + -
- {requestInfo} -
- - poh id - {prettyPohId.slice(0, 20)} - - {prettyPohId.slice(20)} - Open ID - +
+ {requestInfo}
- - + + +
+ } + > + - - {timeline} - +
+ + + +
+
+ {timeline} +
diff --git a/src/app/[pohid]/[chain]/[request]/RequestLoadingSkeleton.tsx b/src/app/[pohid]/[chain]/[request]/RequestLoadingSkeleton.tsx index b0abb6a0..13bf90a9 100644 --- a/src/app/[pohid]/[chain]/[request]/RequestLoadingSkeleton.tsx +++ b/src/app/[pohid]/[chain]/[request]/RequestLoadingSkeleton.tsx @@ -1,58 +1,78 @@ export default function RequestLoadingSkeleton() { return ( -
-
-
-
-
- -
-
-
-
-
-
-
-
+ <> +
+ {/* Action bar */} +
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- -
- -
-
-
-
-
- -
+ {/* Identity card */} +
+
+ {/* Profile aside */} +
+
+
+
+
+
+
+
+
+
+
+
+
-
-
- -
-
-
-
+ {/* Request details */} +
+
+
+
+
+
+
+
+
+
+
+
+
+ {[0, 1, 2].map((index) => ( +
+
+
+ {index < 2 && ( +
+ )} +
+
+
+
+
+
+ ))} +
+
-
-
-
+ {/* Evidence accordion */} +
+
-
+ ); } diff --git a/src/app/[pohid]/[chain]/[request]/RequestVouchSection.tsx b/src/app/[pohid]/[chain]/[request]/RequestVouchSection.tsx index 256bb7f9..ffbb8962 100644 --- a/src/app/[pohid]/[chain]/[request]/RequestVouchSection.tsx +++ b/src/app/[pohid]/[chain]/[request]/RequestVouchSection.tsx @@ -31,9 +31,9 @@ const hasPohId = ( */ export function RequestVouchSectionSkeleton({ title }: { title: string }) { return ( -
+
{title} -
+
{[0, 1, 2].map((index) => (
+
This PoHID vouched for -
+
{visibleItems.map((item, index) => ( +
- {request.status.id === "vouching" - ? "Available vouches for this PoHID" - : "Vouched for this request"} + Vouched by {request.status.id === "vouching" && } -
+
{visibleItems.map((item, index) => ( (null); const [isVisible, setIsVisible] = useState(false); @@ -174,13 +178,21 @@ export default function Timeline({
-

- Timeline History +

+ {title}

{children}
@@ -231,7 +243,9 @@ export default function Timeline({ )}
@@ -240,7 +254,7 @@ export default function Timeline({ href={item.externalHref} target="_blank" rel="noreferrer" - className="group/external-link inline-flex items-center gap-1 font-semibold hover:opacity-80" + className={`group/external-link inline-flex items-center gap-1 font-semibold hover:opacity-80 ${compact ? "text-sm" : ""}`} > {item.title} @@ -250,13 +264,13 @@ export default function Timeline({ ) : item.href ? ( {item.title} ) : ( {item.title} @@ -271,7 +285,9 @@ export default function Timeline({ )}
-
+
{formatter.format(new Date(item.timestamp * 1000))}
diff --git a/src/app/[pohid]/[chain]/[request]/TimelineSection.tsx b/src/app/[pohid]/[chain]/[request]/TimelineSection.tsx index f66b0999..94a2015a 100644 --- a/src/app/[pohid]/[chain]/[request]/TimelineSection.tsx +++ b/src/app/[pohid]/[chain]/[request]/TimelineSection.tsx @@ -16,7 +16,7 @@ export async function RequestInfoSection({ }: TimelineSectionProps) { const { requestCounts } = await timelineDataPromise; - return ; + return ; } export function RequestInfoSectionSkeleton() { @@ -28,7 +28,7 @@ export async function TimelineHistorySection({ }: Pick) { const { timelineItems } = await timelineDataPromise; - return ; + return ; } export function TimelineHistorySectionSkeleton() { diff --git a/src/app/[pohid]/[chain]/[request]/Vouch.tsx b/src/app/[pohid]/[chain]/[request]/Vouch.tsx index 881566cb..d9e634c3 100644 --- a/src/app/[pohid]/[chain]/[request]/Vouch.tsx +++ b/src/app/[pohid]/[chain]/[request]/Vouch.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; -import Modal from "components/Modal"; +import RequestModal from "components/RequestModal"; +import VouchModalContent, { VouchMethod } from "./VouchModalContent"; import usePoHWrite from "contracts/hooks/usePoHWrite"; import { Address, Hash } from "viem"; import { useSignTypedData, useChainId } from "wagmi"; @@ -12,9 +13,24 @@ import ActionButton from "components/ActionButton"; import { useRequestOptimistic } from "optimistic/request"; import type { RequestOptimisticOverlay } from "optimistic/types"; import { resolveTxState } from "utils/txState"; +import { getWriteErrorMessage } from "hooks/useActionFeedback"; const normalizeAddress = (value: Address) => value.toLowerCase(); +const hasExistingVouch = ( + onChainVouches: Address[], + offChainVouches: Array<{ voucher: Address }>, + voucher: Address, +) => { + const normalized = normalizeAddress(voucher); + return ( + onChainVouches.some((value) => normalizeAddress(value) === normalized) || + offChainVouches.some( + (value) => normalizeAddress(value.voucher) === normalized, + ) + ); +}; + export const buildAddVouchSuccessPatch = ( onChainVouches: Address[], offChainVouches: { @@ -25,15 +41,8 @@ export const buildAddVouchSuccessPatch = ( validVouches: number, voucher: Address, ): RequestOptimisticOverlay | undefined => { - const normalized = normalizeAddress(voucher); - const hasOnChain = onChainVouches.some( - (value) => normalizeAddress(value) === normalized, - ); - const hasOffChain = offChainVouches.some( - (value) => normalizeAddress(value.voucher) === normalized, - ); - - if (hasOnChain || hasOffChain) return undefined; + if (hasExistingVouch(onChainVouches, offChainVouches, voucher)) + return undefined; return { onChainVouches: [...onChainVouches, voucher], @@ -51,15 +60,8 @@ export const buildGaslessVouchSuccessPatch = ( validVouches: number, voucher: { voucher: Address; expiration: number; signature: `0x${string}` }, ): RequestOptimisticOverlay | undefined => { - const normalized = normalizeAddress(voucher.voucher); - const hasOnChain = onChainVouches.some( - (value) => normalizeAddress(value) === normalized, - ); - const hasOffChain = offChainVouches.some( - (value) => normalizeAddress(value.voucher) === normalized, - ); - - if (hasOnChain || hasOffChain) return undefined; + if (hasExistingVouch(onChainVouches, offChainVouches, voucher.voucher)) + return undefined; return { offChainVouches: [...offChainVouches, voucher], @@ -68,36 +70,68 @@ export const buildGaslessVouchSuccessPatch = ( }; interface VouchButtonProps { - pohId: Hash; - claimer: Address; - web3Loaded: any; - me: any; chain: SupportedChain; - address: Address | undefined; + onClick: () => void; disabled?: boolean; tooltip?: string; } export default function Vouch({ - pohId, - claimer, - web3Loaded, - me, chain, - address, + onClick, disabled: externalDisabled, tooltip: externalTooltip, }: VouchButtonProps) { - const { effective, pendingAction, applyAction } = useRequestOptimistic(); + const { pendingAction } = useRequestOptimistic(); const userChainId = useChainId(); - const [isOpen, setIsOpen] = useState(false); + const isReconciling = pendingAction !== null; + + const trigger = resolveTxState([ + { active: !!externalDisabled, message: externalTooltip }, + { active: isReconciling, message: "Waiting for indexer" }, + { + active: userChainId !== chain.id, + message: `Switch your chain above to ${idToChain(chain.id)?.name || "the correct chain"}`, + }, + ]); + + return ( + + ); +} + +interface VouchFlowModalProps { + open: boolean; + onClose: () => void; + pohId: Hash; + claimer: Address; + chain: SupportedChain; + address: Address | undefined; +} + +export function VouchFlowModal({ + open, + onClose, + pohId, + claimer, + chain, + address, +}: VouchFlowModalProps) { + const { effective, pendingAction, applyAction } = useRequestOptimistic(); + const [submitted, setSubmitted] = useState(false); const isReconciling = pendingAction !== null; const [prepare, addVouch, status] = usePoHWrite( "addVouch", useMemo( () => ({ - onError() { - toast.error("Transaction rejected"); + onError(error, errorCtx) { + toast.error(getWriteErrorMessage(error, errorCtx)); }, onLoading() { toast.info("Transaction pending"); @@ -114,7 +148,7 @@ export default function Vouch({ applyAction("vouch", patch); } toast.success("Vouched successfully"); - setIsOpen(false); + setSubmitted(true); }, }), [ @@ -132,7 +166,11 @@ export default function Vouch({ }); const isOnchainLoading = - status.prepare === "pending" || status.write === "pending"; + status.prepare === "pending" || + status.write === "pending" || + // Keep the onchain-vouch link locked while the tx is mining, otherwise it + // re-enables after wallet confirmation and allows a duplicate addVouch. + (status.write === "success" && status.transaction === "pending"); const expiration = useMemo( () => Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30 * 6, @@ -165,7 +203,7 @@ export default function Vouch({ applyAction("vouch", patch); } toast.success("Vouched successfully"); - setIsOpen(false); + setSubmitted(true); } catch (err) { console.error(err); toast.error("Error vouching. Please try again."); @@ -191,6 +229,10 @@ export default function Vouch({ ); const { signTypedData, isPending } = useSignTypedData(signTypedDataConfig); + // One lock across both paths (gasless sign + on-chain tx) so they can't + // overlap and the modal can't close mid-submission. + const isSubmitting = isOnchainLoading || isPending; + const gaslessVouch = () => { signTypedData({ domain: { @@ -215,78 +257,26 @@ export default function Vouch({ }); }; - const isRegistrationValid = !me?.expirationTime - ? false - : me.expirationTime > Date.now() / 1000; + const handleVouch = (method: VouchMethod) => { + if (method === "gasless") gaslessVouch(); + else addVouch(); + }; - const trigger = resolveTxState([ - { active: !!externalDisabled, message: externalTooltip }, - { active: isReconciling, message: "Syncing" }, - { - active: userChainId !== chain.id, - message: `Switch your chain above to ${idToChain(chain.id)?.name || "the correct chain"}`, - }, - ]); + const closeModal = () => { + onClose(); + setSubmitted(false); + }; return ( - web3Loaded && - me && - me.homeChain?.id === chain.id && - me.pohId && - isRegistrationValid && ( - <> - setIsOpen(true)} - label="Vouch" - className="mb-2 w-auto" - disabled={trigger.disabled} - tooltip={trigger.tooltip} - /> - setIsOpen(false)} - canClose={!isOnchainLoading} - > -
- - Make sure the person exists and only vouch for people you have - physically encountered. Note that in case a profile is removed for - (Sybil attack) or (Identity theft), all people who had vouched for - it get removed as well. Profiles that do not follow the Policy - risk being challenged and removed. Make sure you read and - understand the Policy before proceeding. Also take into account - that although a gasless vouch is possible, it cannot be removed. - Gasless vouches expire after one year. - - - { - if (isOnchainLoading || isReconciling) return; - addVouch(); - }} - aria-disabled={isOnchainLoading || isReconciling} - aria-busy={isOnchainLoading || isReconciling} - > - or vouch on chain - -
-
- - ) + + + ); } diff --git a/src/app/[pohid]/[chain]/[request]/VouchModalContent.tsx b/src/app/[pohid]/[chain]/[request]/VouchModalContent.tsx new file mode 100644 index 00000000..69b0a74c --- /dev/null +++ b/src/app/[pohid]/[chain]/[request]/VouchModalContent.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState } from "react"; +import cn from "classnames"; +import { + RequestModalActions, + RequestModalHeader, +} from "components/RequestModal"; +import VouchIcon from "icons/Vouch.svg"; +import UsersIcon from "icons/Users.svg"; +import FileTextIcon from "icons/FileText.svg"; +import AlertTriangleIcon from "icons/AlertTriangle.svg"; +import SignatureIcon from "icons/Signature.svg"; +import ChainLinkIcon from "icons/ChainLink.svg"; + +export type VouchMethod = "gasless" | "onchain"; + +const METHOD_OPTIONS: Array<{ + value: VouchMethod; + title: string; + caption: string; + Icon: React.ComponentType>; +}> = [ + { + value: "gasless", + title: "Gasless", + caption: "Free · expires in 6 months · can't be revoked", + Icon: SignatureIcon, + }, + { + value: "onchain", + title: "On-chain", + caption: "Pays gas · removable anytime", + Icon: ChainLinkIcon, + }, +]; + +interface VouchModalContentProps { + submitted: boolean; + onClose: () => void; + onVouch: (method: VouchMethod) => void; + isSubmitting: boolean; + disabled?: boolean; + tooltip?: string; +} + +export default function VouchModalContent({ + submitted, + onClose, + onVouch, + isSubmitting, + disabled, + tooltip, +}: VouchModalContentProps) { + const [acknowledged, setAcknowledged] = useState(false); + const [method, setMethod] = useState("gasless"); + + if (submitted) + return ( + <> + + Success! +
+ Your vouch has been submitted. + + } + description="Thanks!" + /> + + + + ); + + const locked = isSubmitting || !!disabled; + + return ( + <> + + Vouch for this Profile + + } + description="Confirm the statement below to continue." + /> +
+
+
+ + + You know this person and are sure they exist + +
+
+ + + Their submission follows the Policy + +
+
+ + + If this request is rejected as fraudulent (Sybil attack or + identity theft), your profile will be removed alongside it. + +
+
+ +
+ {METHOD_OPTIONS.map((option) => { + const selected = method === option.value; + const Icon = option.Icon; + return ( + + ); + })} +
+
+ onVouch(method)} + primaryDisabled={!acknowledged || locked} + primaryLoading={isSubmitting} + primaryTooltip={ + tooltip ?? + (!acknowledged ? "Confirm the statement above to vouch" : undefined) + } + /> + + ); +} diff --git a/src/app/[pohid]/[chain]/[request]/error.tsx b/src/app/[pohid]/[chain]/[request]/error.tsx index 3196fdfd..d6ab3d08 100644 --- a/src/app/[pohid]/[chain]/[request]/error.tsx +++ b/src/app/[pohid]/[chain]/[request]/error.tsx @@ -14,8 +14,8 @@ export default function RequestError({ }, [error]); return ( -
-
+
+
!
@@ -27,7 +27,7 @@ export default function RequestError({
- {open &&
{children}
} +
+
+ {showChildren && ( + // Once collapsed and settled, hide the persisted content from the + // tab order and a11y tree (`hasOpened` keeps it mounted otherwise). +
+ {children} +
+ )} +
+
); }; diff --git a/src/components/ActionButton.tsx b/src/components/ActionButton.tsx index 4c675f33..470c8979 100644 --- a/src/components/ActionButton.tsx +++ b/src/components/ActionButton.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useId, useState } from "react"; import Image from "next/image"; import { twMerge } from "tailwind-merge"; @@ -23,7 +23,8 @@ const buttonStyles = { secondary: "btn-secondary", }; -const buttonBaseClass = "disabled:cursor-not-allowed"; +const buttonBaseClass = + "disabled:cursor-not-allowed aria-disabled:cursor-not-allowed aria-disabled:opacity-40"; const ActionButton = React.forwardRef( ( @@ -40,6 +41,10 @@ const ActionButton = React.forwardRef( }, ref, ) => { + const tooltipId = useId(); + const [tooltipVisible, setTooltipVisible] = useState(false); + const isDisabled = disabled || isLoading; + const mergedButtonClasses = twMerge( buttonStyles[variant], buttonBaseClass, @@ -47,7 +52,7 @@ const ActionButton = React.forwardRef( ); const mergedWrapperClasses = twMerge( - "relative group w-full md:w-fit", + "relative flex w-full justify-center md:w-fit", fullWidth && "md:w-full", ); @@ -66,15 +71,24 @@ const ActionButton = React.forwardRef( ); + // When the button has a tooltip it stays focusable while unavailable + // (`aria-disabled` + click guard instead of the native `disabled` + // attribute), so keyboard and screen-reader users can reach the + // explanation of why the action is unavailable. const button = ( @@ -82,9 +96,25 @@ const ActionButton = React.forwardRef( if (tooltip) { return ( -
+
setTooltipVisible(true)} + onMouseLeave={() => setTooltipVisible(false)} + onFocus={() => setTooltipVisible(true)} + onBlur={() => setTooltipVisible(false)} + onKeyDown={(event) => { + if (event.key === "Escape") setTooltipVisible(false); + }} + > {button} - + {tooltip}
diff --git a/src/components/AddEvidenceModal.tsx b/src/components/AddEvidenceModal.tsx new file mode 100644 index 00000000..373bd547 --- /dev/null +++ b/src/components/AddEvidenceModal.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { useAtlasProvider } from "@kleros/kleros-app"; +import ActionButton from "components/ActionButton"; +import AuthGuard from "components/AuthGuard"; +import EvidenceFormFields from "components/EvidenceFormFields"; +import RequestModal, { RequestModalHeader } from "components/RequestModal"; +import { Effects } from "contracts/hooks/types"; +import usePoHWrite from "contracts/hooks/usePoHWrite"; +import { uploadEvidence } from "data/uploadEvidence"; +import { useRequestOptimistic } from "optimistic/request"; +import type { + OptimisticEvidenceItem, + RequestOptimisticOverlay, +} from "optimistic/types"; +import { idToChain } from "config/chains"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { toast } from "react-toastify"; +import { Address, Hash } from "viem"; +import { useAccount, useChainId } from "wagmi"; +import { getWriteErrorMessage } from "hooks/useActionFeedback"; + +const buildEvidenceSuccessItem = ( + uri: string, + submitter: Address, + name: string, + description: string, + fileURI?: string, + txHash?: string, +): OptimisticEvidenceItem => ({ + id: `optimistic-evidence-${txHash ?? Date.now()}`, + uri, + creationTime: Math.floor(Date.now() / 1000), + submitter, + name, + description, + fileURI, +}); + +const buildEvidenceSuccessPatch = ( + uri: string, + submitter: Address, + name: string, + description: string, + fileURI?: string, + txHash?: string, +): RequestOptimisticOverlay => ({ + evidenceList: [ + buildEvidenceSuccessItem( + uri, + submitter, + name, + description, + fileURI, + txHash, + ), + ], +}); + +interface AddEvidenceModalProps { + open: boolean; + onClose: () => void; + pohId: Hash; + requestIndex: number; + chainId: number; +} + +export default function AddEvidenceModal({ + open, + onClose, + pohId, + requestIndex, + chainId, +}: AddEvidenceModalProps) { + const { pendingAction, applyAction } = useRequestOptimistic(); + const isReconciling = pendingAction !== null; + const { address } = useAccount(); + const userChainId = useChainId(); + const wrongChain = userChainId !== chainId; + const wrongChainTooltip = `Switch your chain above to ${idToChain(chainId)?.name || "the correct chain"}`; + const [pending, setPending] = useState(false); + const [loadingMessage, setLoadingMessage] = useState(); + const startLoading = useCallback((message?: string) => { + setPending(true); + setLoadingMessage(message); + }, []); + const stopLoading = useCallback(() => { + setPending(false); + setLoadingMessage(undefined); + }, []); + + // Latest evidence metadata, read by the write `onSuccess` handler after the + // tx resolves. A ref (not state) reads the current value without a stale + // closure and keeps the tx-effects memo stable (no re-prepare per keystroke). + const metaRef = useRef({ name: "", description: "", fileURI: "" }); + const [title, setTitle] = useState(""); + const [titleTouched, setTitleTouched] = useState(false); + const [description, setDescription] = useState(""); + const [file, setFile] = useState(null); + + const showTitleError = titleTouched && !title.trim(); + + const resetEvidenceState = useCallback(() => { + metaRef.current = { name: "", description: "", fileURI: "" }; + }, []); + + const closeModal = useCallback(() => { + onClose(); + setTitle(""); + setTitleTouched(false); + setDescription(""); + setFile(null); + resetEvidenceState(); + stopLoading(); + }, [onClose, resetEvidenceState, stopLoading]); + + const { uploadFile } = useAtlasProvider(); + const [prepare] = usePoHWrite( + "submitEvidence", + useMemo( + () => ({ + onReady(fire) { + fire(); + startLoading("Transaction pending"); + toast.info("Transaction pending"); + }, + onFail() { + resetEvidenceState(); + stopLoading(); + toast.error("Transaction failed"); + }, + onError(error, errorCtx) { + resetEvidenceState(); + stopLoading(); + toast.error(getWriteErrorMessage(error, errorCtx)); + }, + onSuccess(ctx) { + const uri = + typeof ctx.args?.[2] === "string" ? ctx.args[2] : undefined; + if (address && uri) { + applyAction( + "evidence", + buildEvidenceSuccessPatch( + uri, + address, + metaRef.current.name, + metaRef.current.description, + metaRef.current.fileURI || undefined, + ctx.txHash, + ), + ); + } + toast.success("Evidence submitted successfully"); + closeModal(); + }, + }), + [ + address, + applyAction, + closeModal, + resetEvidenceState, + startLoading, + stopLoading, + ], + ), + ); + + const submit = async () => { + // Re-check the chain here: the wallet may have switched networks after the + // modal opened, and IPFS upload happens before the contract write. + if (wrongChain) return; + startLoading("Uploading evidence..."); + + try { + const { evidenceUri, fileURI } = await uploadEvidence(uploadFile, { + name: title, + description, + file, + }); + + metaRef.current = { name: title, description, fileURI: fileURI || "" }; + prepare({ args: [pohId, BigInt(requestIndex), evidenceUri] }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to upload evidence.", + ); + stopLoading(); + } + }; + + return ( + + + Add New Evidence + + } + description={ + <> +

+ When someone challenges a profile, a case is opened in Kleros + Court. A group of random jurors is selected to review the case. + They look at the evidence from both sides and vote. +

+

+ The side with the most votes wins the dispute. Providing clear + evidence is important. It helps the jurors understand the case and + make a fair decision. +

+ + } + /> + setTitleTouched(true)} + onDescriptionChange={setDescription} + onFileChange={setFile} + disabled={pending} + titleError={showTitleError} + /> +
+ + + + +
+
+ ); +} diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index e7992e36..a928b464 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -9,7 +9,7 @@ const COPIED_RESET_MS = 2000; export default function CopyButton({ value, - label = "Copy POH ID", + label = "Copy", className, }: { value: string; @@ -64,8 +64,9 @@ export default function CopyButton({ /> diff --git a/src/components/CurrencyField.tsx b/src/components/CurrencyField.tsx index 022a3576..409f9a5b 100644 --- a/src/components/CurrencyField.tsx +++ b/src/components/CurrencyField.tsx @@ -7,13 +7,14 @@ import Label from "./Label"; type CurrencyFieldProps = InputHTMLAttributes & { label?: ReactNode; + labelClassName?: string; /** Native currency symbol deciding the token icon, e.g. "ETH" or "xDAI". */ symbol?: string; /** When set, the token becomes a selector with a caret. */ onTokenClick?: () => void; }; -const tokenIcon = (symbol?: string) => +export const CurrencyIcon = ({ symbol }: { symbol?: string }) => /dai/i.test(symbol ?? "") ? ( ) : ( @@ -22,6 +23,7 @@ const tokenIcon = (symbol?: string) => function CurrencyField({ label, + labelClassName, symbol, onTokenClick, className, @@ -29,7 +31,7 @@ function CurrencyField({ }: CurrencyFieldProps) { return (
- {label && } + {label && }
- {tokenIcon(symbol)} - + + ) : ( - tokenIcon(symbol) + )}
diff --git a/src/components/EvidenceFormFields.tsx b/src/components/EvidenceFormFields.tsx new file mode 100644 index 00000000..5adfb7da --- /dev/null +++ b/src/components/EvidenceFormFields.tsx @@ -0,0 +1,71 @@ +import Field from "components/Field"; +import FileUploadZone from "components/FileUploadZone"; + +interface EvidenceFormFieldsProps { + title?: string; + description: string; + file: File | null; + onTitleChange?: (value: string) => void; + onTitleBlur?: () => void; + onDescriptionChange: (value: string) => void; + onFileChange: (file: File) => void; + disabled?: boolean; + titlePlaceholder?: string; + hideTitle?: boolean; + titleError?: boolean; +} + +export default function EvidenceFormFields({ + title = "", + description, + file, + onTitleChange, + onTitleBlur, + onDescriptionChange, + onFileChange, + disabled, + titlePlaceholder = "eg. The profile is legit.", + hideTitle = false, + titleError = false, +}: EvidenceFormFieldsProps) { + return ( +
+ {!hideTitle && ( + onTitleChange?.(event.target.value)} + onBlur={onTitleBlur} + status={titleError ? "error" : undefined} + message="A title is required" + disabled={disabled} + /> + )} + onDescriptionChange(event.target.value)} + disabled={disabled} + /> + { + const acceptedFile = acceptedFiles[0]; + if (acceptedFile) onFileChange(acceptedFile); + }} + placeholder={Upload a file} + footnote="Attach supporting file. It will be uploaded to IPFS and included with your evidence." + footnoteIconClassName="text-status-claim" + disabled={disabled} + /> +
+ ); +} diff --git a/src/components/Field.tsx b/src/components/Field.tsx index 3d487e31..22dd48be 100644 --- a/src/components/Field.tsx +++ b/src/components/Field.tsx @@ -7,6 +7,7 @@ type FieldStatus = "error"; type FieldCommonProps = { label?: ReactNode; + labelClassName?: string; status?: FieldStatus; message?: ReactNode; }; @@ -27,6 +28,7 @@ const statusMessage: Record = { function Field({ label, + labelClassName, textarea = false, status, message, @@ -35,7 +37,7 @@ function Field({ }: FieldProps) { return (
- {label && } + {label && }
{uploaded ? ( <> - + {fileName} ) : ( <> - + {placeholder} )} {footnote && ( - + {footnote} )} diff --git a/src/components/IdentityReferenceRow.tsx b/src/components/IdentityReferenceRow.tsx new file mode 100644 index 00000000..61f9b2f2 --- /dev/null +++ b/src/components/IdentityReferenceRow.tsx @@ -0,0 +1,85 @@ +import ChainLogo from "components/ChainLogo"; +import CopyButton from "components/CopyButton"; +import ExternalLink from "components/ExternalLink"; +import { idToChain } from "config/chains"; +import ArrowRight from "icons/ArrowRight.svg"; +import Link from "next/link"; +import type { ReactNode } from "react"; +import cn from "classnames"; + +interface IdentityReferenceRowProps { + chainId: number; + children: ReactNode; + href: string; + value: string; + external?: boolean; + compact?: boolean; +} + +export default function IdentityReferenceRow({ + chainId, + children, + href, + value, + external = false, + compact = false, +}: IdentityReferenceRowProps) { + const linkClassName = cn( + "icon-btn h-8 w-8", + compact ? "order-3" : "order-2 md:order-3", + ); + const linkContent = ; + + return ( +
+
+
+ {children} +
+ + {idToChain(chainId)?.name} chain +
+ {external ? ( + + {linkContent} + + ) : ( + + {linkContent} + + )} +
+ {value} + +
+
+ ); +} diff --git a/src/components/InfoTooltip.tsx b/src/components/InfoTooltip.tsx new file mode 100644 index 00000000..d3e39cfb --- /dev/null +++ b/src/components/InfoTooltip.tsx @@ -0,0 +1,60 @@ +"use client"; + +import cn from "classnames"; +import InfoIcon from "icons/info.svg"; +import { useId, useState, type ReactNode } from "react"; + +interface InfoTooltipProps { + label: ReactNode; + children: ReactNode; + className?: string; +} + +export default function InfoTooltip({ + label, + children, + className, +}: InfoTooltipProps) { + const [open, setOpen] = useState(false); + const tooltipId = useId(); + const show = () => setOpen(true); + const hide = () => setOpen(false); + + return ( + + + + + {children} + + + ); +} diff --git a/src/components/LoadableImage.tsx b/src/components/LoadableImage.tsx index a90e2242..52aa612f 100644 --- a/src/components/LoadableImage.tsx +++ b/src/components/LoadableImage.tsx @@ -43,7 +43,11 @@ export default function LoadableImage({ {alt} setIsLoading(false)} onError={() => { diff --git a/src/components/Progress.tsx b/src/components/Progress.tsx index 9840e57b..01ff794b 100644 --- a/src/components/Progress.tsx +++ b/src/components/Progress.tsx @@ -1,14 +1,28 @@ +import cn from "classnames"; + interface ProgressProps { value: number; label: string; + labelClassName?: string; } -const Progress: React.FC = ({ value, label }) => ( -
-
{label}
-
+const Progress: React.FC = ({ + value, + label, + labelClassName, +}) => ( +
+
+ {label} +
+
diff --git a/src/components/Request/Grid.tsx b/src/components/Request/Grid.tsx index 7d25be79..68e278b8 100644 --- a/src/components/Request/Grid.tsx +++ b/src/components/Request/Grid.tsx @@ -244,7 +244,9 @@ function RequestsGrid() { ) { const where: any = { ...getRequestStatusFilter(status), - ...(search ? { claimer_: { name_contains: search } } : {}), + ...(search + ? { claimer_: { name_contains_nocase: search } } + : {}), }; const skipNumber = loadContinued diff --git a/src/components/Request/StatusBadge.tsx b/src/components/Request/StatusBadge.tsx index a36ee6c4..fd743eb9 100644 --- a/src/components/Request/StatusBadge.tsx +++ b/src/components/Request/StatusBadge.tsx @@ -3,14 +3,27 @@ import { getStatusColor, getStatusLabel, RequestStatus } from "utils/status"; interface StatusBadgeProps { status: RequestStatus; + size?: "compact" | "large"; } -const StatusBadge: React.FC = ({ status }) => { +const StatusBadge: React.FC = ({ + status, + size = "compact", +}) => { const color = getStatusColor(status); return ( - - + + {getStatusLabel(status)} ); diff --git a/src/components/RequestModal.tsx b/src/components/RequestModal.tsx new file mode 100644 index 00000000..71a86a9b --- /dev/null +++ b/src/components/RequestModal.tsx @@ -0,0 +1,130 @@ +"use client"; + +import Modal from "components/Modal"; +import ActionButton from "components/ActionButton"; +import cn from "classnames"; +import type { ReactNode } from "react"; + +interface RequestModalProps { + open: boolean; + onClose: () => void; + canClose?: boolean; + children: ReactNode; + className?: string; +} + +export default function RequestModal({ + open, + onClose, + canClose, + children, + className, +}: RequestModalProps) { + return ( + +
+ {children} +
+
+ ); +} + +export function RequestModalHeader({ + title, + description, +}: { + title: ReactNode; + description?: ReactNode; +}) { + return ( +
+

{title}

+ {description && ( +
+ {description} +
+ )} +
+ ); +} + +export function RequestModalActions({ + onReturn, + returnDisabled, + primaryLabel, + onPrimary, + primaryDisabled, + primaryLoading, + primaryTooltip, + primaryClassName, + returnLabel = "Return", +}: { + onReturn: () => void; + returnDisabled?: boolean; + primaryLabel?: ReactNode; + onPrimary?: () => void; + primaryDisabled?: boolean; + primaryLoading?: boolean; + primaryTooltip?: string; + primaryClassName?: string; + returnLabel?: ReactNode; +}) { + return ( +
+ {primaryLabel && onPrimary && ( + + )} + +
+ ); +} + +export function RequestAmountPill({ + amount, + icon, +}: { + amount: ReactNode; + icon?: ReactNode; +}) { + return ( +
+ {amount} + {icon} +
+ ); +} + +export function RequestWarning({ children }: { children: ReactNode }) { + return ( +
+ + ! + +
+ + Important! + + {children} +
+
+ ); +} diff --git a/src/components/RequestPunishedVouchNotice.tsx b/src/components/RequestPunishedVouchNotice.tsx index 59b47487..d5f016c2 100644 --- a/src/components/RequestPunishedVouchNotice.tsx +++ b/src/components/RequestPunishedVouchNotice.tsx @@ -17,13 +17,13 @@ export default function RequestPunishedVouchNotice({ const punishedAt = timestamp ? Number(timestamp) : null; return ( -
-
+
+
!
-
-
+
+
Punished vouch removal
diff --git a/src/components/StatusIcon.tsx b/src/components/StatusIcon.tsx index 13582ea9..3796de0b 100644 --- a/src/components/StatusIcon.tsx +++ b/src/components/StatusIcon.tsx @@ -15,6 +15,7 @@ const STATUS_ICONS: Record>> = { claim: EyeIcon, registered: CheckCircleOutlineIcon, challenged: ChallengeIcon, + removed: CloseCircleOutlineIcon, rejected: CloseCircleOutlineIcon, expired: HourglassIcon, transferred: TransferIcon, diff --git a/src/components/VideoThumbnail.tsx b/src/components/VideoThumbnail.tsx index 35676df0..53d91435 100644 --- a/src/components/VideoThumbnail.tsx +++ b/src/components/VideoThumbnail.tsx @@ -13,8 +13,8 @@ export default function VideoThumbnail({ src, className, }: VideoThumbnailProps) { - const [isLoading, setIsLoading] = useState(true); const [hasError, setHasError] = useState(false); + const [isLoading, setIsLoading] = useState(true); // Append #t=0.001 to force iOS Safari to render first frame const videoSrc = src.includes("#") ? src : `${src}#t=0.001`; @@ -25,29 +25,26 @@ export default function VideoThumbnail({ className, )} > - {isLoading && !hasError ? ( - - ) : null} {hasError ? ( + ) : isLoading ? ( + ) : null}
); diff --git a/src/config/chains.ts b/src/config/chains.ts index 9092e8be..191d6534 100644 --- a/src/config/chains.ts +++ b/src/config/chains.ts @@ -88,6 +88,12 @@ export function idToChainAny(id: number): AnySupportedChain | null { return idToChainMain(id) ?? idToChainTest(id); } +/** Display label for a chain's native currency. */ +export function nativeCurrencyLabel(chainId: number): string { + const symbol = idToChain(chainId)?.nativeCurrency.symbol ?? ""; + return /xdai/i.test(symbol) ? "xDAI" : symbol; +} + export function paramToChain(param: string): SupportedChain | null { if (nameToChain(param)) return nameToChain(param); else return idToChain(+param); diff --git a/src/contracts/hooks/types.ts b/src/contracts/hooks/types.ts index 5b4e3860..de727508 100644 --- a/src/contracts/hooks/types.ts +++ b/src/contracts/hooks/types.ts @@ -27,9 +27,28 @@ export interface WriteSuccessContext { receipt?: TransactionReceipt; } +/** + * Why a submitted write terminally failed: + * - `wallet`: rejected or failed at submission, never reached the chain. + * - `reverted`: mined and reverted on-chain. + * - `unknown`: the receipt lookup failed; the tx may still confirm, so the + * hook keeps its duplicate-write guard closed. + */ +export type WriteErrorKind = "wallet" | "reverted" | "unknown"; + +export interface WriteErrorContext { + kind: WriteErrorKind; + txHash?: Hash; +} + export interface Effects { onLoading?: () => void; - onError?: (error?: unknown) => void; + /** + * Terminal notification for every write that does not reach `onSuccess`. + * Exactly one of `onSuccess`/`onError` fires per submitted transaction, so + * callers can rely on it to release loading/lock state. + */ + onError?: (error?: unknown, errorCtx?: WriteErrorContext) => void; onFail?: (error?: unknown) => void; onSuccess?: (ctx: WriteSuccessContext) => void; onReady?: (fire: () => void) => void; diff --git a/src/contracts/hooks/useWagmiWrite.ts b/src/contracts/hooks/useWagmiWrite.ts index 12e06acc..2ecac547 100644 --- a/src/contracts/hooks/useWagmiWrite.ts +++ b/src/contracts/hooks/useWagmiWrite.ts @@ -15,7 +15,14 @@ import { } from "wagmi"; import useChainParam from "hooks/useChainParam"; import { getContractInfo, ContractName } from "contracts"; -import { Abi, Hash, ParseAbiParameter, toBytes, zeroAddress } from "viem"; +import { + Abi, + BaseError, + Hash, + ParseAbiParameter, + toBytes, + zeroAddress, +} from "viem"; const defaultForInputs = (inputs: readonly ParseAbiParameter[]) => inputs.length @@ -69,11 +76,14 @@ export default function useWagmiWrite< } as any); const { writeContractAsync, status, error: writeError } = useWriteContract(); - const { data: receipt, status: transactionStatus } = - useWaitForTransactionReceipt({ - hash: submittedTx?.hash, - chainId: submittedTx?.chainId, - }); + const { + data: receipt, + status: transactionStatus, + error: transactionError, + } = useWaitForTransactionReceipt({ + hash: submittedTx?.hash, + chainId: submittedTx?.chainId, + }); const effectsRef = useRef(effects); const lastWriteRef = useRef<{ args?: readonly unknown[]; @@ -81,8 +91,10 @@ export default function useWagmiWrite< chainId: number; } | null>(null); const preparedRequestRef = useRef(); + const writeInFlightRef = useRef(false); const lastPendingHashRef = useRef(); - const lastSuccessHashRef = useRef(); + const lastSettledHashRef = useRef(); + const lastUnknownHashRef = useRef(); useEffect(() => { effectsRef.current = effects; @@ -94,6 +106,8 @@ export default function useWagmiWrite< const fireWrite = useCallback( (request: any) => { + if (writeInFlightRef.current) return; + writeInFlightRef.current = true; const writeChainId = (request?.chain?.id as SupportedChainId | undefined) ?? currentChainId; lastWriteRef.current = { @@ -106,7 +120,9 @@ export default function useWagmiWrite< .then((hash) => { setSubmittedTx({ hash, chainId: writeChainId }); }) - .catch(() => undefined); + .catch(() => { + writeInFlightRef.current = false; + }); }, [args, value, currentChainId, writeContractAsync], ); @@ -132,11 +148,14 @@ export default function useWagmiWrite< useEffect(() => { switch (status) { case "error": - effectsRef.current?.onError?.(writeError); + writeInFlightRef.current = false; + effectsRef.current?.onError?.(writeError, { kind: "wallet" }); setEnabled(false); } }, [status, writeError]); + // Settle each submitted tx exactly once, through either `onSuccess` or + // `onError` — callers key their loading/lock state off that guarantee. useEffect(() => { const txHash = submittedTx?.hash; if (!txHash) return; @@ -148,24 +167,48 @@ export default function useWagmiWrite< effectsRef.current?.onLoading?.(); } break; - case "success": - if (lastSuccessHashRef.current !== txHash) { - lastSuccessHashRef.current = txHash; - const ctx: WriteSuccessContext = { - contract, - functionName: String(functionName), - args: lastWriteRef.current?.args, - value: lastWriteRef.current?.value, - chainId: lastWriteRef.current?.chainId ?? currentChainId, - txHash, - receipt, - }; - effectsRef.current?.onSuccess?.(ctx); + case "success": { + if (lastSettledHashRef.current === txHash) break; + lastSettledHashRef.current = txHash; + writeInFlightRef.current = false; + const ctx: WriteSuccessContext = { + contract, + functionName: String(functionName), + args: lastWriteRef.current?.args, + value: lastWriteRef.current?.value, + chainId: lastWriteRef.current?.chainId ?? currentChainId, + // A sped-up (repriced) tx confirms under a different hash, and the + // wait resolves with that replacement receipt. + txHash: receipt?.transactionHash ?? txHash, + receipt, + }; + effectsRef.current?.onSuccess?.(ctx); + break; + } + case "error": { + if (transactionError instanceof BaseError) { + if (lastUnknownHashRef.current !== txHash) { + lastUnknownHashRef.current = txHash; + effectsRef.current?.onError?.(transactionError, { + kind: "unknown", + txHash, + }); + } + break; } + if (lastSettledHashRef.current === txHash) break; + lastSettledHashRef.current = txHash; + writeInFlightRef.current = false; + effectsRef.current?.onError?.(transactionError, { + kind: "reverted", + txHash, + }); break; + } } }, [ transactionStatus, + transactionError, submittedTx?.hash, receipt, contract, diff --git a/src/data/evidence.ts b/src/data/evidence.ts index 200273b7..63265ae8 100644 --- a/src/data/evidence.ts +++ b/src/data/evidence.ts @@ -1,5 +1,8 @@ +import { supportedChains } from "config/chains"; +import { getClaimerData } from "data/claimer"; import type { EvidenceFile, RegistrationFile } from "types/docs"; import { ipfsFetch } from "utils/ipfs"; +import type { Address } from "viem"; /** * Follows registration evidence to the nested claim payload and returns the @@ -18,3 +21,68 @@ export const getRegistrationPhoto = async (evidenceUri?: string) => { .then((registrationFile) => registrationFile?.photo ?? null) .catch(() => null); }; + +export interface EvidenceSubmitterProfile { + pohId: string; + name?: string; + photo?: string; +} + +/** + * Resolves which evidence submitters are registered PoH humans and maps them + * to their profile (PoH ID, display name and registration photo). Only + * verified submitters are included, so callers can render the profile + * photo/name and link to the profile page, falling back to identicon + + * address + block explorer for everyone else. Keyed by lowercased address. + */ +export const getEvidenceSubmitterProfiles = async ( + submitters: string[], +): Promise> => { + const unique = Array.from( + new Set(submitters.map((submitter) => submitter.toLowerCase())), + ); + + const lookupProfile = async ( + address: string, + ): Promise<[string, EvidenceSubmitterProfile | null]> => { + try { + const raw = await getClaimerData(address as Address); + const registeredChain = supportedChains.find( + (chain) => raw[chain.id]?.claimer?.registration?.humanity?.id, + ); + if (!registeredChain) return [address, null]; + + const claimer = raw[registeredChain.id].claimer; + const pohId = claimer?.registration?.humanity?.id ?? null; + if (!pohId) return [address, null]; + + // Same hop as the vouch avatars: winning claim evidence points at + // the registration file, which holds the profile photo. + const evidenceUri = claimer?.registration?.humanity.winnerClaim + .at(0) + ?.evidenceGroup.evidence.at(0)?.uri; + const photo = (await getRegistrationPhoto(evidenceUri)) ?? undefined; + + return [address, { pohId, name: claimer?.name ?? undefined, photo }]; + } catch { + return [address, null]; + } + }; + + // Cap concurrency so a request with many distinct submitters doesn't fan out + // into an unbounded burst of subgraph/IPFS calls. + const CONCURRENCY = 5; + const entries: [string, EvidenceSubmitterProfile | null][] = []; + for (let start = 0; start < unique.length; start += CONCURRENCY) { + const batch = await Promise.all( + unique.slice(start, start + CONCURRENCY).map(lookupProfile), + ); + entries.push(...batch); + } + + return Object.fromEntries( + entries.filter( + (entry): entry is [string, EvidenceSubmitterProfile] => entry[1] !== null, + ), + ); +}; diff --git a/src/generated/graphql.ts b/src/generated/graphql.ts index f8969639..150f3bcf 100644 --- a/src/generated/graphql.ts +++ b/src/generated/graphql.ts @@ -16,7 +16,9 @@ export type Scalars = { BigDecimal: any; BigInt: any; Bytes: any; + /** 8 bytes signed integer */ Int8: any; + /** A string representation of microseconds UNIX timestamp (16 digits) */ Timestamp: any; }; @@ -3833,7 +3835,7 @@ export type HistoricalWinnerClaimQueryVariables = Exact<{ }>; -export type HistoricalWinnerClaimQuery = { __typename?: 'Query', requests: Array<{ __typename?: 'Request', creationTime: any, index: any, lastStatusChange: any, requester: any, resolutionTime: any, claimer: { __typename?: 'Claimer', id: any, name?: string | null }, evidenceGroup: { __typename?: 'EvidenceGroup', evidence: Array<{ __typename?: 'Evidence', uri: string }> } }> }; +export type HistoricalWinnerClaimQuery = { __typename?: 'Query', requests: Array<{ __typename?: 'Request', creationTime: any, index: any, lastStatusChange: any, requester: any, resolutionTime: any, claimer: { __typename?: 'Claimer', id: any, name?: string | null }, evidenceGroup: { __typename?: 'EvidenceGroup', evidence: Array<{ __typename?: 'Evidence', uri: string, creationTime: any, submitter: any }> } }> }; export type HumanityQueryVariables = Exact<{ id: Scalars['ID']; @@ -3909,6 +3911,13 @@ export type RewardClaimQueryVariables = Exact<{ export type RewardClaimQuery = { __typename?: 'Query', rewardClaim?: { __typename?: 'RewardClaim', id: any, amount: any, timestamp: any, claimer: { __typename?: 'Claimer', id: any } } | null }; +export type SubmitterLatestClaimQueryVariables = Exact<{ + address: Scalars['String']; +}>; + + +export type SubmitterLatestClaimQuery = { __typename?: 'Query', requests: Array<{ __typename?: 'Request', creationTime: any, humanity: { __typename?: 'Humanity', id: any }, claimer: { __typename?: 'Claimer', id: any, name?: string | null }, evidenceGroup: { __typename?: 'EvidenceGroup', evidence: Array<{ __typename?: 'Evidence', uri: string }> } }> }; + export type IsSyncedQueryVariables = Exact<{ block: Scalars['Int']; }>; @@ -4120,6 +4129,8 @@ export const HistoricalWinnerClaimDocument = gql` evidenceGroup { evidence(orderBy: creationTime, orderDirection: asc, first: 1) { uri + creationTime + submitter } } } @@ -4491,6 +4502,30 @@ export const RewardClaimDocument = gql` } } `; +export const SubmitterLatestClaimDocument = gql` + query SubmitterLatestClaim($address: String!) { + requests( + where: {claimer: $address, revocation: false, status_not_in: ["transferring", "transferred"], evidenceGroup_: {length_gt: 0}} + first: 1 + orderBy: creationTime + orderDirection: desc + ) { + creationTime + humanity { + id + } + claimer { + id + name + } + evidenceGroup { + evidence(orderBy: creationTime, orderDirection: asc, first: 1) { + uri + } + } + } +} + `; export const IsSyncedDocument = gql` query IsSynced($block: Int!) { _meta(block: {number: $block}) { @@ -4578,6 +4613,9 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = RewardClaim(variables: RewardClaimQueryVariables, requestHeaders?: GraphQLClientRequestHeaders, signal?: RequestInit['signal']): Promise { return withWrapper((wrappedRequestHeaders) => client.request({ document: RewardClaimDocument, variables, requestHeaders: { ...requestHeaders, ...wrappedRequestHeaders }, signal }), 'RewardClaim', 'query', variables); }, + SubmitterLatestClaim(variables: SubmitterLatestClaimQueryVariables, requestHeaders?: GraphQLClientRequestHeaders, signal?: RequestInit['signal']): Promise { + return withWrapper((wrappedRequestHeaders) => client.request({ document: SubmitterLatestClaimDocument, variables, requestHeaders: { ...requestHeaders, ...wrappedRequestHeaders }, signal }), 'SubmitterLatestClaim', 'query', variables); + }, IsSynced(variables: IsSyncedQueryVariables, requestHeaders?: GraphQLClientRequestHeaders, signal?: RequestInit['signal']): Promise { return withWrapper((wrappedRequestHeaders) => client.request({ document: IsSyncedDocument, variables, requestHeaders: { ...requestHeaders, ...wrappedRequestHeaders }, signal }), 'IsSynced', 'query', variables); }, diff --git a/src/hooks/useActionFeedback.ts b/src/hooks/useActionFeedback.ts index 60dc2486..76a12898 100644 --- a/src/hooks/useActionFeedback.ts +++ b/src/hooks/useActionFeedback.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react"; import { TransactionRejectedRpcError, UserRejectedRequestError } from "viem"; +import type { WriteErrorContext, WriteErrorKind } from "contracts/hooks/types"; export const ACTION_STATES = { idle: "idle", @@ -64,13 +65,25 @@ export const isActionStateError = (state: ControlledActionState) => const DEFAULT_WRITE_ERROR_MESSAGE = "Transaction failed. Check your wallet and try again."; +// Kind-specific copy for terminal write outcomes reported by the write hook. +// "wallet" is absent on purpose: wallet failures fall through to +// `isWalletRejectedError`, which separates a user rejection from other +// submission failures. +const WRITE_ERROR_KIND_MESSAGES: Partial> = { + reverted: "Transaction failed on-chain.", + unknown: + "Could not confirm the transaction. Check the block explorer before retrying.", +}; + export const getWriteErrorMessage = ( error: unknown, + errorCtx?: WriteErrorContext, fallbackMessage = DEFAULT_WRITE_ERROR_MESSAGE, ) => - isWalletRejectedError(error) + (errorCtx && WRITE_ERROR_KIND_MESSAGES[errorCtx.kind]) ?? + (isWalletRejectedError(error) ? ACTION_STATE_LABELS[ACTION_STATES.walletRejected] - : fallbackMessage; + : fallbackMessage); const getActionFeedbackMessage = ({ state, detail }: ActionFeedback) => { if (state === ACTION_STATES.idle) { @@ -103,13 +116,18 @@ export default function useActionFeedback() { ); const setWriteError = useCallback( - (error: unknown, fallbackMessage = DEFAULT_WRITE_ERROR_MESSAGE) => { + ( + error: unknown, + errorCtx?: WriteErrorContext, + fallbackMessage = DEFAULT_WRITE_ERROR_MESSAGE, + ) => { + const message = getWriteErrorMessage(error, errorCtx, fallbackMessage); setActionFeedback( isWalletRejectedError(error) ? { state: ACTION_STATES.walletRejected } - : { state: ACTION_STATES.error, detail: fallbackMessage }, + : { state: ACTION_STATES.error, detail: message }, ); - return getWriteErrorMessage(error, fallbackMessage); + return message; }, [], ); diff --git a/src/hooks/useFundingAmount.ts b/src/hooks/useFundingAmount.ts new file mode 100644 index 00000000..726ddb0b --- /dev/null +++ b/src/hooks/useFundingAmount.ts @@ -0,0 +1,92 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { formatEther, parseEther } from "viem"; +import { useAccount, useChainId } from "wagmi"; +import { idToChain, nativeCurrencyLabel } from "config/chains"; +import useEnoughFunds from "hooks/useEnoughFunds"; +import { formatEth } from "utils/misc"; +import { resolveTxState, type TxCheck } from "utils/txState"; + +interface FundingAmountOptions { + chainId: number; + funded: bigint; + totalCost: bigint; + checks?: TxCheck[]; + /** Pre-fill (and reset) the input with the full remaining amount. */ + defaultToRemaining?: boolean; +} + +export default function useFundingAmount({ + chainId, + funded, + totalCost, + checks = [], + defaultToRemaining = false, +}: FundingAmountOptions) { + const remainingAmount = totalCost > funded ? totalCost - funded : 0n; + const defaultInput = + defaultToRemaining && remainingAmount > 0n + ? formatEther(remainingAmount) + : ""; + const [input, setInput] = useState(defaultInput); + // `defaultInput` is recomputed every render, so reset restores the *current* + // remaining amount, not the one captured on mount. + const resetInput = useCallback(() => setInput(defaultInput), [defaultInput]); + const { isConnected } = useAccount(); + const connectedChainId = useChainId(); + const unit = nativeCurrencyLabel(chainId); + + // Parsed wei value of the input. Sentinels: `null` = unparsable text, + // `0n` = empty input (negatives are clamped to 0). + const inputAmount = useMemo(() => { + if (!input) return 0n; + try { + const parsed = parseEther(input); + return parsed < 0n ? 0n : parsed; + } catch { + return null; + } + }, [input]); + + const isInvalidInput = inputAmount === null; + const isZeroInput = inputAmount === 0n; + const exceedsRemaining = + inputAmount !== null && inputAmount > remainingAmount; + const balanceCheck = useEnoughFunds({ + chainId, + // `undefined` skips the balance check until there's a valid positive amount. + amount: inputAmount !== null && inputAmount > 0n ? inputAmount : undefined, + }); + const isWrongChain = connectedChainId !== chainId; + // Order matters: the first active check with a message supplies the tooltip, + // so checks go from most fundamental (connection) to most specific (balance). + const txState = resolveTxState([ + ...checks, + { active: !isConnected, message: "Please connect your wallet" }, + { + active: isWrongChain, + message: `Switch your chain above to ${idToChain(chainId)?.name || "the correct chain"}`, + }, + { active: !input, message: "Please enter an amount to fund" }, + { active: isInvalidInput, message: "Please enter a valid amount" }, + { active: isZeroInput, message: "Amount must be greater than 0" }, + { + active: exceedsRemaining, + message: `Amount exceeds remaining needed (${formatEth(remainingAmount)} ${unit})`, + }, + { active: balanceCheck.isLoading, message: "Checking balance" }, + { active: balanceCheck.insufficient, message: balanceCheck.message }, + ]); + + return { + input, + setInput, + resetInput, + inputAmount, + remainingAmount, + unit, + isWrongChain, + ...txState, + }; +} diff --git a/src/icons/AlertTriangle.svg b/src/icons/AlertTriangle.svg new file mode 100644 index 00000000..629482d2 --- /dev/null +++ b/src/icons/AlertTriangle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/icons/ArrowRight.svg b/src/icons/ArrowRight.svg new file mode 100644 index 00000000..e980eb91 --- /dev/null +++ b/src/icons/ArrowRight.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/ChainLink.svg b/src/icons/ChainLink.svg new file mode 100644 index 00000000..b1fb8759 --- /dev/null +++ b/src/icons/ChainLink.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/icons/FileText.svg b/src/icons/FileText.svg new file mode 100644 index 00000000..a1479c7c --- /dev/null +++ b/src/icons/FileText.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/icons/Signature.svg b/src/icons/Signature.svg new file mode 100644 index 00000000..9b6600aa --- /dev/null +++ b/src/icons/Signature.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/Users.svg b/src/icons/Users.svg new file mode 100644 index 00000000..2161df96 --- /dev/null +++ b/src/icons/Users.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/icons/XdaiToken.svg b/src/icons/XdaiToken.svg index 59b4457a..f298518e 100644 --- a/src/icons/XdaiToken.svg +++ b/src/icons/XdaiToken.svg @@ -1,6 +1,4 @@ - - - - - + + + diff --git a/src/icons/upload.svg b/src/icons/upload.svg index 5dd345ae..c29d132c 100644 --- a/src/icons/upload.svg +++ b/src/icons/upload.svg @@ -1,10 +1,10 @@ - + - + diff --git a/src/utils/ipfs.ts b/src/utils/ipfs.ts index f2afb652..8412b718 100644 --- a/src/utils/ipfs.ts +++ b/src/utils/ipfs.ts @@ -46,5 +46,5 @@ export const ipfsFetch = async (ipfsURI: string) => { if (!url) throw new Error("Invalid IPFS URI."); - return (await axios.get(url)).data as F; + return (await axios.get(url, { timeout: 10_000 })).data as F; }; diff --git a/tailwind.config.cjs b/tailwind.config.cjs index 9669943e..0e91bd05 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -90,6 +90,10 @@ module.exports = { "wizardOutRight 0.18s cubic-bezier(0.22, 0.61, 0.36, 1) forwards", fadeOut: "fadeOut 0.4s ease-out forwards", fadeIn: "fadeIn 0.3s ease-out forwards", + accordionItemIn: + "accordionItemIn 0.6s cubic-bezier(0.22, 0.61, 0.36, 1) both", + mediaResolve: + "mediaResolve 0.6s cubic-bezier(0.22, 0.61, 0.36, 1) both", }, keyframes: { flip: { @@ -120,6 +124,18 @@ module.exports = { "0%": { opacity: "0" }, "100%": { opacity: "1" }, }, + accordionItemIn: { + "0%": { opacity: "0", transform: "translateY(12px)" }, + "100%": { opacity: "1", transform: "translateY(0)" }, + }, + mediaResolve: { + "0%": { + opacity: "0", + filter: "blur(12px)", + transform: "scale(1.02)", + }, + "100%": { opacity: "1", filter: "blur(0)", transform: "scale(1)" }, + }, // progress: { // "0%": { transform: "scaleX(1)" }, // "100%": { transform: "scaleX(0)" },