diff --git a/web/src/components/CasesDisplay/CasesGrid.tsx b/web/src/components/CasesDisplay/CasesGrid.tsx index 3f8d2bfe6..d2a95598a 100644 --- a/web/src/components/CasesDisplay/CasesGrid.tsx +++ b/web/src/components/CasesDisplay/CasesGrid.tsx @@ -1,14 +1,13 @@ -import React, { useMemo } from "react"; +import React from "react"; import styled from "styled-components"; -import { useWindowSize } from "react-use"; import { useParams } from "react-router-dom"; import { SkeletonDisputeCard, SkeletonDisputeListItem } from "../StyledSkeleton"; import { StandardPagination } from "@kleros/ui-components-library"; -import { BREAKPOINT_LANDSCAPE } from "styles/landscapeStyle"; import { useIsList } from "context/IsListProvider"; import { isUndefined } from "utils/index"; import { decodeURIFilter } from "utils/uri"; import { DisputeDetailsFragment } from "queries/useCasesQuery"; +import useIsDesktop from "hooks/useIsDesktop"; import DisputeCard from "components/DisputeCard"; import CasesListHeader from "./CasesListHeader"; @@ -46,12 +45,11 @@ const CasesGrid: React.FC = ({ disputes, casesPerPage, totalPages, c const decodedFilter = decodeURIFilter(filter ?? "all"); const { id: searchValue } = decodedFilter; const { isList } = useIsList(); - const { width } = useWindowSize(); - const screenIsBig = useMemo(() => width > BREAKPOINT_LANDSCAPE, [width]); + const isDesktop = useIsDesktop(); return ( <> - {isList && screenIsBig ? ( + {isList && isDesktop ? ( {isUndefined(disputes) diff --git a/web/src/components/CasesDisplay/Filters.tsx b/web/src/components/CasesDisplay/Filters.tsx index 01b5cc639..ebc332eaa 100644 --- a/web/src/components/CasesDisplay/Filters.tsx +++ b/web/src/components/CasesDisplay/Filters.tsx @@ -1,12 +1,11 @@ import React from "react"; import styled, { useTheme } from "styled-components"; import { useNavigate, useParams } from "react-router-dom"; -import { useWindowSize } from "react-use"; import { DropdownSelect } from "@kleros/ui-components-library"; import { useIsList } from "context/IsListProvider"; +import useIsDesktop from "hooks/useIsDesktop"; import ListIcon from "svgs/icons/list.svg"; import GridIcon from "svgs/icons/grid.svg"; -import { BREAKPOINT_LANDSCAPE } from "styles/landscapeStyle"; import { decodeURIFilter, encodeURIFilter, useRootPath } from "utils/uri"; const Container = styled.div` @@ -59,9 +58,8 @@ const Filters: React.FC = () => { navigate(`${location}/1/${value}/${encodedFilter}`); }; - const { width } = useWindowSize(); const { isList, setIsList } = useIsList(); - const screenIsBig = width > BREAKPOINT_LANDSCAPE; + const isDesktop = useIsDesktop(); return ( @@ -87,14 +85,14 @@ const Filters: React.FC = () => { defaultValue={order} callback={handleOrderChange} /> - {screenIsBig ? ( + {isDesktop ? ( {isList ? ( setIsList(false)} /> ) : ( { - if (screenIsBig) { + if (isDesktop) { setIsList(true); } }} diff --git a/web/src/components/HeroImage.tsx b/web/src/components/HeroImage.tsx index 3170244b5..d07c177f9 100644 --- a/web/src/components/HeroImage.tsx +++ b/web/src/components/HeroImage.tsx @@ -1,18 +1,16 @@ import React from "react"; import { useTheme } from "styled-components"; -import { useWindowSize } from "react-use"; -import { BREAKPOINT_LANDSCAPE } from "styles/landscapeStyle"; import HeroLightMobile from "tsx:svgs/hero/hero-lightmode-mobile.svg"; import HeroDarkMobile from "tsx:svgs/hero/hero-darkmode-mobile.svg"; import HeroLightDesktop from "tsx:svgs/hero/hero-lightmode-desktop.svg"; import HeroDarkDesktop from "tsx:svgs/hero/hero-darkmode-desktop.svg"; +import useIsDesktop from "hooks/useIsDesktop"; const HeroImage = () => { - const { width } = useWindowSize(); const theme = useTheme(); const themeIsLight = theme.name === "light"; - const screenIsBig = width > BREAKPOINT_LANDSCAPE; - return
{screenIsBig ? : }
; + const isDesktop = useIsDesktop(); + return
{isDesktop ? : }
; }; const HeroDesktop: React.FC<{ themeIsLight: boolean }> = ({ themeIsLight }) => { diff --git a/web/src/components/InfoCard.tsx b/web/src/components/InfoCard.tsx new file mode 100644 index 000000000..17ff32dbd --- /dev/null +++ b/web/src/components/InfoCard.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import styled from "styled-components"; +import { responsiveSize } from "styles/responsiveSize"; +import InfoCircle from "tsx:svgs/icons/info-circle.svg"; + +const InfoContainer = styled.div` + display: grid; + grid-template-columns: 16px max-content; + gap: ${responsiveSize(4, 8, 300)}; + align-items: center; + justify-items: center; + text-align: center; + color: ${({ theme }) => theme.secondaryText}; +`; + +interface IInfoCard { + msg: string; + className?: string; +} + +const InfoCard: React.FC = ({ msg, className }) => { + return ( + + + {msg} + + ); +}; + +export default InfoCard; diff --git a/web/src/components/Popup/ExtraInfo/VoteWithCommitExtraInfo.tsx b/web/src/components/Popup/ExtraInfo/VoteWithCommitExtraInfo.tsx index 11003f32a..b0a8a096f 100644 --- a/web/src/components/Popup/ExtraInfo/VoteWithCommitExtraInfo.tsx +++ b/web/src/components/Popup/ExtraInfo/VoteWithCommitExtraInfo.tsx @@ -1,37 +1,16 @@ import React from "react"; import styled from "styled-components"; -import InfoCircle from "tsx:svgs/icons/info-circle.svg"; +import InfoCard from "components/InfoCard"; import { responsiveSize } from "styles/responsiveSize"; -const Container = styled.div` - display: flex; - gap: 4px; - text-align: center; +const StyledInfoCard = styled(InfoCard)` margin: ${responsiveSize(8, 24, 300)} ${responsiveSize(8, 32, 300)} 0; font-size: 14px; font-weight: 400; line-height: 19px; - color: ${({ theme }) => theme.secondaryText}; -`; - -const InfoCircleContainer = styled.div` - display: flex; - margin-top: 2px; - - svg { - min-width: 16px; - min-height: 16px; - } `; const VoteWithCommitExtraInfo: React.FC = () => { - return ( - - - - - Subscribe to receive notifications to be reminded when the reveal time comes. - - ); + return ; }; export default VoteWithCommitExtraInfo; diff --git a/web/src/consts/eip712-messages.ts b/web/src/consts/eip712-messages.ts index 01ba1494d..4e5f05f50 100644 --- a/web/src/consts/eip712-messages.ts +++ b/web/src/consts/eip712-messages.ts @@ -1,11 +1,19 @@ +import { arbitrumSepolia } from "viem/chains"; + export default { - contactDetails: (address: `0x${string}`, nonce, telegram = "", email = "") => + contactDetails: ( + address: `0x${string}`, + nonce: string, + telegram = "", + email = "", + chainId: number = arbitrumSepolia.id + ) => ({ address: address.toLowerCase() as `0x${string}`, domain: { name: "Kleros v2", version: "1", - chainId: 421_613, + chainId, }, types: { ContactDetails: [ @@ -21,4 +29,23 @@ export default { nonce, }, } as const), + signingAccount: (address: `0x${string}`, chainId: number = arbitrumSepolia.id) => + ({ + account: address.toLowerCase() as `0x${string}`, + domain: { + name: "Kleros v2", + version: "1", + chainId, + }, + types: { + SigningAccount: [{ name: "body", type: "string" }], + }, + primaryType: "SigningAccount", + message: { + body: + "To keep your data safe and to use certain features of Kleros, we ask that you sign these message to " + + "create a secret key for your account. This key is unrelated from your main Ethereum account and will " + + "not be able to send any transactions.", + }, + } as const), }; diff --git a/web/src/hooks/queries/useDrawQuery.ts b/web/src/hooks/queries/useDrawQuery.ts index 7f69184fa..f7219442d 100644 --- a/web/src/hooks/queries/useDrawQuery.ts +++ b/web/src/hooks/queries/useDrawQuery.ts @@ -8,6 +8,12 @@ const drawQuery = graphql(` query Draw($address: String, $disputeID: String, $roundID: String) { draws(first: 1000, where: { dispute: $disputeID, juror: $address, round: $roundID }) { voteIDNum + vote { + ... on ClassicVote { + commit + commited + } + } } } `); diff --git a/web/src/hooks/useIsDesktop.tsx b/web/src/hooks/useIsDesktop.tsx new file mode 100644 index 000000000..1b0928f12 --- /dev/null +++ b/web/src/hooks/useIsDesktop.tsx @@ -0,0 +1,10 @@ +import { useMemo } from "react"; +import { useWindowSize } from "react-use"; +import { BREAKPOINT_LANDSCAPE } from "styles/landscapeStyle"; + +const useIsDesktop = () => { + const { width } = useWindowSize(); + return useMemo(() => width > BREAKPOINT_LANDSCAPE, [width]); +}; + +export default useIsDesktop; diff --git a/web/src/hooks/useSigningAccount.tsx b/web/src/hooks/useSigningAccount.tsx new file mode 100644 index 000000000..1966e3f47 --- /dev/null +++ b/web/src/hooks/useSigningAccount.tsx @@ -0,0 +1,27 @@ +import { useLocalStorage } from "react-use"; +import { Hex, WalletClient, keccak256 } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { useWalletClient } from "wagmi"; +import messages from "consts/eip712-messages"; +import { isUndefined } from "utils/index"; + +const useSigningAccount = () => { + const { data: wallet } = useWalletClient(); + const address = wallet?.account.address; + const key = `signingAccount-${address}`; + const [signingKey, setSigningKey] = useLocalStorage(key); + return { + signingAccount: !isUndefined(signingKey) ? privateKeyToAccount(signingKey) : undefined, + generateSigningAccount: () => (!isUndefined(wallet) ? generateSigningAccount(wallet, setSigningKey) : undefined), + }; +}; + +const generateSigningAccount = async (wallet: WalletClient, setSigningKey: (signingKey: `0x${string}`) => void) => { + if (isUndefined(wallet.account)) return; + const signature = await wallet.signTypedData(messages.signingAccount(wallet.account.address)); + const signingKey = keccak256(signature); + setSigningKey(signingKey); + return privateKeyToAccount(signingKey); +}; + +export default useSigningAccount; diff --git a/web/src/pages/Cases/CaseDetails/Timeline.tsx b/web/src/pages/Cases/CaseDetails/Timeline.tsx index 58e703ae0..cf54a6e4a 100644 --- a/web/src/pages/Cases/CaseDetails/Timeline.tsx +++ b/web/src/pages/Cases/CaseDetails/Timeline.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import styled, { css } from "styled-components"; import { landscapeStyle } from "styles/landscapeStyle"; import { Periods } from "consts/periods"; @@ -6,6 +6,7 @@ import { DisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; import { Box, Steps } from "@kleros/ui-components-library"; import { StyledSkeleton } from "components/StyledSkeleton"; import { useCountdown } from "hooks/useCountdown"; +import useIsDesktop from "hooks/useIsDesktop"; import { secondsToDayHourMinute } from "utils/date"; import { responsiveSize } from "styles/responsiveSize"; @@ -34,29 +35,11 @@ const StyledSteps = styled(Steps)` margin: auto; `; -const TitleMobile = styled.span` - ${landscapeStyle( - () => css` - display: none; - ` - )} -`; - -const TitleDesktop = styled(TitleMobile)` - display: none; - - ${landscapeStyle( - () => css` - display: inline-block; - ` - )} -`; - const Timeline: React.FC<{ dispute: DisputeDetailsQuery["dispute"]; currentPeriodIndex: number; }> = ({ currentPeriodIndex, dispute }) => { - const currentItemIndex = currentPeriodToCurrentItem(currentPeriodIndex, dispute?.ruled); + const currentItemIndex = currentPeriodToCurrentItem(currentPeriodIndex, dispute?.court.hiddenVotes); const items = useTimeline(dispute, currentItemIndex, currentItemIndex); return ( @@ -65,19 +48,21 @@ const Timeline: React.FC<{ ); }; -const currentPeriodToCurrentItem = (currentPeriodIndex: number, ruled?: boolean): number => { +const currentPeriodToCurrentItem = (currentPeriodIndex: number, hiddenVotes?: boolean): number => { + if (hiddenVotes) return currentPeriodIndex; if (currentPeriodIndex <= Periods.commit) return currentPeriodIndex; - else if (currentPeriodIndex < Periods.execution) return currentPeriodIndex - 1; - else return ruled ? 5 : currentPeriodIndex - 1; + else return currentPeriodIndex - 1; }; const useTimeline = (dispute: DisputeDetailsQuery["dispute"], currentItemIndex: number, currentPeriodIndex: number) => { - const titles = [ - { mobile: "Evidence", desktop: "Evidence Period" }, - { mobile: "Voting", desktop: "Voting Period" }, - { mobile: "Appeal", desktop: "Appeal Period" }, - { mobile: "Executed", desktop: "Executed" }, - ]; + const isDesktop = useIsDesktop(); + const titles = useMemo(() => { + const titles = ["Evidence", "Voting", "Appeal", "Executed"]; + if (dispute?.court.hiddenVotes) { + titles.splice(1, 0, "Commit"); + } + return titles; + }, [dispute]); const deadlineCurrentPeriod = getDeadline( currentPeriodIndex, dispute?.lastPeriodChange, @@ -86,12 +71,12 @@ const useTimeline = (dispute: DisputeDetailsQuery["dispute"], currentItemIndex: const countdown = useCountdown(deadlineCurrentPeriod); const getSubitems = (index: number): string[] | React.ReactNode[] => { if (typeof countdown !== "undefined" && dispute) { - if (index === currentItemIndex && countdown === 0) { + if (index === titles.length - 1) { + return []; + } else if (index === currentItemIndex && countdown === 0) { return ["Time's up!"]; } else if (index < currentItemIndex) { return []; - } else if (index === 3) { - return currentItemIndex === 3 ? ["Pending"] : []; } else if (index === currentItemIndex) { return [secondsToDayHourMinute(countdown)]; } else { @@ -101,12 +86,7 @@ const useTimeline = (dispute: DisputeDetailsQuery["dispute"], currentItemIndex: return []; }; return titles.map((title, i) => ({ - title: ( - <> - {title.mobile} - {title.desktop} - - ), + title: i + 1 < titles.length && isDesktop ? `${title} Period` : title, subitems: getSubitems(i), })); }; diff --git a/web/src/pages/Cases/CaseDetails/Voting/Classic.tsx b/web/src/pages/Cases/CaseDetails/Voting/Classic.tsx deleted file mode 100644 index c26588ddf..000000000 --- a/web/src/pages/Cases/CaseDetails/Voting/Classic.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import React, { useMemo, useState } from "react"; -import styled from "styled-components"; -import { useParams } from "react-router-dom"; -import { useWalletClient, usePublicClient } from "wagmi"; -import { Button, Textarea } from "@kleros/ui-components-library"; -import { prepareWriteDisputeKitClassic } from "hooks/contracts/generated"; -import { wrapWithToast } from "utils/wrapWithToast"; -import { useDisputeTemplate } from "queries/useDisputeTemplate"; -import { useDisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; -import { EnsureChain } from "components/EnsureChain"; -import ReactMarkdown from "react-markdown"; - -const Container = styled.div` - width: 100%; - height: auto; -`; -const MainContainer = styled.div` - width: 100%; - height: auto; -`; -const StyledTextarea = styled(Textarea)` - width: 100%; - height: auto; - textarea { - height: 200px; - border-color: ${({ theme }) => theme.stroke}; - } - small { - font-weight: 400; - hyphens: auto; - } -`; -const OptionsContainer = styled.div` - margin-top: 24px; - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 16px; -`; -const RefuseToArbitrateContainer = styled.div` - width: 100%; - background-color: ${({ theme }) => theme.lightBlue}; - padding: 32px; - display: flex; - justify-content: center; -`; - -interface IClassic { - arbitrable: `0x${string}`; - voteIDs: string[]; - setIsOpen: (val: boolean) => void; -} - -const Classic: React.FC = ({ arbitrable, voteIDs, setIsOpen }) => { - const { id } = useParams(); - const parsedDisputeID = BigInt(id ?? 0); - const parsedVoteIDs = useMemo(() => voteIDs.map((voteID) => BigInt(voteID)), [voteIDs]); - const { data: disputeTemplate } = useDisputeTemplate(id, arbitrable); - const { data: disputeData } = useDisputeDetailsQuery(id); - const [chosenOption, setChosenOption] = useState(-1); - const [isSending, setIsSending] = useState(false); - const [justification, setJustification] = useState(""); - const { data: walletClient } = useWalletClient(); - const publicClient = usePublicClient(); - - const handleVote = async (voteOption: number) => { - setIsSending(true); - setChosenOption(voteOption); - const { request } = await prepareWriteDisputeKitClassic({ - functionName: "castVote", - args: [ - parsedDisputeID, - parsedVoteIDs, - BigInt(voteOption), - BigInt(disputeData?.dispute?.currentRoundIndex), - justification, - ], - }); - if (walletClient) { - wrapWithToast(async () => await walletClient.writeContract(request), publicClient) - .then(() => { - setIsOpen(true); - }) - .finally(() => { - setChosenOption(-1); - setIsSending(false); - }); - } - }; - - return id ? ( - - - {disputeTemplate?.question} - setJustification(e.target.value)} - placeholder="Justify your vote..." - message={ - "A good justification contributes to case comprehension. " + "Low quality justifications can be challenged." - } - variant="info" - /> - - {disputeTemplate?.answers?.map((answer: { title: string; description: string }, i: number) => { - return ( - -