diff --git a/packages/next-common/components/data/recovery/hooks/useQueryAllRecoveryData.js b/packages/next-common/components/data/recovery/hooks/useQueryAllFriendGroups.js similarity index 88% rename from packages/next-common/components/data/recovery/hooks/useQueryAllRecoveryData.js rename to packages/next-common/components/data/recovery/hooks/useQueryAllFriendGroups.js index df56c3d169..659b48daaf 100644 --- a/packages/next-common/components/data/recovery/hooks/useQueryAllRecoveryData.js +++ b/packages/next-common/components/data/recovery/hooks/useQueryAllFriendGroups.js @@ -1,5 +1,5 @@ import { useContextApi } from "next-common/context/api"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; export function flattenRecoveryData(data) { if (!data || data.length === 0) { @@ -24,10 +24,13 @@ export function flattenRecoveryData(data) { return rows; } -export default function useQueryAllRecoveryData() { +export default function useQueryAllFriendGroups() { const api = useContextApi(); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); + const [fetchCount, setFetchCount] = useState(0); + + const fetch = useCallback(() => setFetchCount((c) => c + 1), []); useEffect(() => { if (!api) { @@ -83,7 +86,7 @@ export default function useQueryAllRecoveryData() { return () => { cancelled = true; }; - }, [api]); + }, [api, fetchCount]); - return { data, loading }; + return { data, loading, fetch }; } diff --git a/packages/next-common/components/data/recovery/hooks/useQueryAllRecoveryAttempts.js b/packages/next-common/components/data/recovery/hooks/useQueryAllRecoveryAttempts.js index 650f4ee07b..389c9a0ff2 100644 --- a/packages/next-common/components/data/recovery/hooks/useQueryAllRecoveryAttempts.js +++ b/packages/next-common/components/data/recovery/hooks/useQueryAllRecoveryAttempts.js @@ -1,5 +1,5 @@ import { useContextApi } from "next-common/context/api"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; function bitfieldToIndices(bitfield) { const indices = []; @@ -17,10 +17,70 @@ function bitfieldToIndices(bitfield) { return indices; } +export function buildFriendGroupsData(friendGroupsMap) { + return Object.entries(friendGroupsMap).map(([account, groups]) => ({ + account, + friendGroups: groups.map((group, index) => ({ + index, + friends: group.friends || [], + friendsNeeded: parseInt(group.friendsNeeded) || 0, + inheritor: group.inheritor || "", + inheritancePriority: parseInt(group.inheritancePriority) || 0, + inheritanceDelay: parseInt(group.inheritanceDelay) || 0, + cancelDelay: parseInt(group.cancelDelay) || 0, + })), + })); +} + +function processAttempts(entries, friendGroupsMap) { + return entries.map(([storageKey, value]) => { + const lostAccount = storageKey.args?.[0]?.toString(); + const friendGroupIndex = storageKey.args?.[1]?.toNumber(); + + const json = value.toJSON(); + const attempt = json?.[0] || {}; + + const approvalsBitfield = attempt.approvals || []; + const approvedIndices = bitfieldToIndices(approvalsBitfield); + const approvalsCount = approvedIndices.length; + + const friendGroup = + (friendGroupsMap[lostAccount] || [])[friendGroupIndex] || {}; + const friends = friendGroup.friends || []; + const approvedAddresses = approvedIndices + .filter((i) => i < friends.length) + .map((i) => friends[i]); + + return { + lostAccount, + friendGroupIndex, + initiator: attempt.initiator || "", + initBlock: parseInt(attempt.initBlock) || 0, + lastApprovalBlock: parseInt(attempt.lastApprovalBlock) || 0, + approvalsCount, + approvedAddresses, + friendsNeeded: parseInt(friendGroup.friendsNeeded) || 0, + }; + }); +} + +/** + * Fetch all recovery attempts and their friend groups. + * + * @returns {{ data: Array, loading: boolean, fetch: () => void, friendGroupsMap: object }} + * - data: all processed recovery attempt objects + * - loading: whether data is being fetched + * - fetch: trigger a re-fetch + * - friendGroupsMap: raw map of lostAccount -> friendGroups for building friendGroupsData + */ export default function useQueryAllRecoveryAttempts() { const api = useContextApi(); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); + const [friendGroupsMap, setFriendGroupsMap] = useState({}); + const [fetchCount, setFetchCount] = useState(0); + + const fetch = useCallback(() => setFetchCount((c) => c + 1), []); useEffect(() => { if (!api) { @@ -30,6 +90,7 @@ export default function useQueryAllRecoveryAttempts() { if (!api?.query.recovery?.attempt) { setLoading(false); setData([]); + setFriendGroupsMap({}); return; } @@ -41,56 +102,30 @@ export default function useQueryAllRecoveryAttempts() { .then(async (entries) => { if (cancelled) return; - // Build a map of (lostAccount) -> friendGroups to resolve bitfield indices to addresses const lostAccounts = [ ...new Set(entries.map(([key]) => key.args?.[0]?.toString())), ]; - const friendGroupsMap = {}; + const fgMap = {}; await Promise.all( lostAccounts.map(async (account) => { try { const value = await api.query.recovery.friendGroups(account); const json = value.toJSON(); - friendGroupsMap[account] = json?.[0] || []; + fgMap[account] = json?.[0] || []; } catch { - friendGroupsMap[account] = []; + fgMap[account] = []; } }), ); - const result = entries.map(([storageKey, value]) => { - const lostAccount = storageKey.args?.[0]?.toString(); - const friendGroupIndex = storageKey.args?.[1]?.toNumber(); - - const json = value.toJSON(); - const attempt = json?.[0] || {}; - - const approvalsBitfield = attempt.approvals || []; - const approvedIndices = bitfieldToIndices(approvalsBitfield); - const approvalsCount = approvedIndices.length; - - // Resolve approved indices to actual friend addresses - const friendGroup = - (friendGroupsMap[lostAccount] || [])[friendGroupIndex] || {}; - const friends = friendGroup.friends || []; - const approvedAddresses = approvedIndices - .filter((i) => i < friends.length) - .map((i) => friends[i]); - - return { - lostAccount, - friendGroupIndex, - initiator: attempt.initiator || "", - initBlock: parseInt(attempt.initBlock) || 0, - lastApprovalBlock: parseInt(attempt.lastApprovalBlock) || 0, - approvalsCount, - approvedAddresses, - }; - }); + if (cancelled) return; + + const result = processAttempts(entries, fgMap); if (!cancelled) { setData(result); + setFriendGroupsMap(fgMap); setLoading(false); } }) @@ -98,6 +133,7 @@ export default function useQueryAllRecoveryAttempts() { console.error("Failed to query recovery attempts", error); if (!cancelled) { setData([]); + setFriendGroupsMap({}); setLoading(false); } }); @@ -105,7 +141,7 @@ export default function useQueryAllRecoveryAttempts() { return () => { cancelled = true; }; - }, [api]); + }, [api, fetchCount]); - return { data, loading }; + return { data, loading, fetch, friendGroupsMap }; } diff --git a/packages/next-common/components/data/recovery/hooks/useQueryAllRecoveryInheritors.js b/packages/next-common/components/data/recovery/hooks/useQueryAllRecoveryInheritors.js index f29d905207..ae09a20a4b 100644 --- a/packages/next-common/components/data/recovery/hooks/useQueryAllRecoveryInheritors.js +++ b/packages/next-common/components/data/recovery/hooks/useQueryAllRecoveryInheritors.js @@ -1,10 +1,13 @@ import { useContextApi } from "next-common/context/api"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; export default function useQueryAllRecoveryInheritors() { const api = useContextApi(); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); + const [fetchCount, setFetchCount] = useState(0); + + const fetch = useCallback(() => setFetchCount((c) => c + 1), []); useEffect(() => { if (!api) { @@ -58,7 +61,7 @@ export default function useQueryAllRecoveryInheritors() { return () => { cancelled = true; }; - }, [api]); + }, [api, fetchCount]); - return { data, loading }; + return { data, loading, fetch }; } diff --git a/packages/next-common/components/data/recovery/index.jsx b/packages/next-common/components/data/recovery/index.jsx index 774e118b73..c657492214 100644 --- a/packages/next-common/components/data/recovery/index.jsx +++ b/packages/next-common/components/data/recovery/index.jsx @@ -7,9 +7,9 @@ import PageHeader from "../common/pageHeader"; import RecoveryFriendGroupsTable from "./table"; import RecoveryAttemptsTable from "./table/attempts"; import RecoveryInheritorsTable from "./table/inheritors"; -import useQueryAllRecoveryData, { +import useQueryAllFriendGroups, { flattenRecoveryData, -} from "./hooks/useQueryAllRecoveryData"; +} from "./hooks/useQueryAllFriendGroups"; import useQueryAllRecoveryAttempts from "./hooks/useQueryAllRecoveryAttempts"; import useQueryAllRecoveryInheritors from "./hooks/useQueryAllRecoveryInheritors"; import { searchAddress } from "./table"; @@ -21,7 +21,7 @@ export default function RecoveryExplorer() { const search = router.query.search || ""; const { data: friendGroupsData, loading: friendGroupsLoading } = - useQueryAllRecoveryData(); + useQueryAllFriendGroups(); const { data: attemptsData, loading: attemptsLoading } = useQueryAllRecoveryAttempts(); const { data: inheritorsData, loading: inheritorsLoading } = diff --git a/packages/next-common/components/data/recovery/table/attemptsColumns.jsx b/packages/next-common/components/data/recovery/table/attemptsColumns.jsx index cffb51d62b..ccb619dcd7 100644 --- a/packages/next-common/components/data/recovery/table/attemptsColumns.jsx +++ b/packages/next-common/components/data/recovery/table/attemptsColumns.jsx @@ -1,178 +1,19 @@ -import AddressUser from "next-common/components/user/addressUser"; -import Tooltip from "next-common/components/tooltip"; -import { AddressesTooltip } from "next-common/components/multisigs/fields"; -import { useRelayChainApi } from "next-common/context/relayChain"; -import { useChainSettings } from "next-common/context/chain"; -import { estimateBlocksTime } from "next-common/utils"; -import useCall from "next-common/utils/hooks/useCall"; -import { isNil } from "lodash-es"; - -function BlockNumberWithTooltip({ height }) { - const api = useRelayChainApi(); - const { blockTime } = useChainSettings(); - const { value: currentNumber } = useCall(api?.query?.system?.number, []); - const currentHeight = currentNumber?.toNumber(); - - if (isNil(height) || isNil(currentHeight)) { - return ( - - #{height?.toLocaleString() || 0} - - ); - } - - const diff = Math.max(0, currentHeight - height); - const estimatedTime = diff > 0 ? estimateBlocksTime(diff, blockTime) : null; - - return ( - - - #{height?.toLocaleString() || 0} - - - ); -} +import { attemptColumns } from "next-common/components/recovery/common/columns"; export const desktopColumns = [ - { - name: "Lost Account", - className: "min-w-[200px] text-left", - render: (item) => ( - - ), - }, - { - name: "Group Index", - className: "w-[120px] text-left", - render: (item) => ( - - ) - } - > - - #{item.friendGroupIndex} - - - ), - }, - { - name: "Initiator", - className: "min-w-[200px] text-left", - render: (item) => ( - - ), - }, - { - name: "Init Block", - className: "w-[180px] text-left", - render: (item) => , - }, - { - name: "Last Approval Block", - className: "w-[200px] text-left", - render: (item) => ( - - ), - }, - { - name: "Threshold / Approvals", - className: "w-[160px] text-right", - render: (item) => ( - 0 && ( - - ) - } - > - - {item.fgGroup && ( - - {item.fgGroup?.friendsNeeded || 0} /{" "} - - )} - {item.approvalsCount} - - - ), - }, + attemptColumns.lostAccount("min-w-[200px] text-left"), + attemptColumns.groupIndex("w-[120px] text-left"), + attemptColumns.initiator("min-w-[200px] text-left"), + attemptColumns.initBlock("w-[180px] text-left"), + attemptColumns.lastApprovalBlock("w-[200px] text-left"), + attemptColumns.thresholdApprovals("w-[160px] text-right"), ]; export const mobileColumns = [ - { - name: "Lost Account", - className: "text-left", - render: (item) => , - }, - { - name: "Group Index", - className: "text-right", - render: (item) => ( - - ) - } - > - - #{item.friendGroupIndex} - - - ), - }, - { - name: "Initiator", - className: "text-left", - render: (item) => , - }, - { - name: "Init Block", - className: "text-right", - render: (item) => , - }, - { - name: "Last Approval Block", - className: "text-right", - render: (item) => ( - - ), - }, - { - name: "Threshold / Approvals", - className: "text-right", - render: (item) => ( - 0 && ( - - ) - } - > - - {item.fgGroup && ( - - {item.fgGroup?.friendsNeeded || 0} /{" "} - - )} - {item.approvalsCount} - - - ), - }, + attemptColumns.lostAccount("text-left"), + attemptColumns.groupIndex("text-right"), + attemptColumns.initiator("text-left"), + attemptColumns.initBlock("text-right"), + attemptColumns.lastApprovalBlock("text-right"), + attemptColumns.thresholdApprovals("text-right"), ]; diff --git a/packages/next-common/components/data/recovery/table/columns.jsx b/packages/next-common/components/data/recovery/table/columns.jsx index 0b5e683918..d3d7f076b6 100644 --- a/packages/next-common/components/data/recovery/table/columns.jsx +++ b/packages/next-common/components/data/recovery/table/columns.jsx @@ -1,149 +1,23 @@ -import AddressUser from "next-common/components/user/addressUser"; -import Tooltip from "next-common/components/tooltip"; -import { AddressesTooltip } from "next-common/components/multisigs/fields"; -import { useEstimateBlocksTime } from "next-common/utils/hooks"; -import { isNil } from "lodash-es"; - -function FriendsCount({ friends = [] }) { - if (isNil(friends)) { - return null; - } - - return ( - } - > - - {friends?.length || 0} - - - ); -} - -function DelayBlock({ blocks }) { - const estimatedTime = useEstimateBlocksTime(blocks); - - if (isNil(blocks)) { - return null; - } - - return ( - - - {blocks?.toLocaleString() || 0} - - - ); -} +import { friendGroupColumns } from "next-common/components/recovery/common/columns"; export const desktopColumns = [ - { - name: "Account", - className: "min-w-[200px] text-left", - render: (item) => ( - - ), - }, - { - name: "Group Index", - className: "w-[120px] text-left", - render: (item) => ( - #{item.index} - ), - }, - { - name: "Priority", - className: "w-[100px] text-left", - render: (item) => ( - - {item.inheritancePriority} - - ), - }, - { - name: "Friends", - className: "w-[100px] text-left", - render: (item) => , - }, - { - name: "Threshold", - className: "w-[120px] text-left", - render: (item) => ( - - {item.friendsNeeded} - - ), - }, - { - name: "Inheritor", - className: "min-w-[160px] text-left", - render: (item) => ( - - ), - }, - { - name: "Inheritance Delay", - className: "w-[180px] text-left", - render: (item) => , - }, - { - name: "Cancel Delay", - className: "w-[160px] text-right", - render: (item) => , - }, + friendGroupColumns.account("min-w-[200px] text-left"), + friendGroupColumns.groupIndex("w-[120px] text-left"), + friendGroupColumns.priority("w-[100px] text-left"), + friendGroupColumns.friends("w-[100px] text-left"), + friendGroupColumns.threshold("w-[120px] text-left"), + friendGroupColumns.inheritor("min-w-[160px] text-left"), + friendGroupColumns.inheritanceDelay("w-[180px] text-left"), + friendGroupColumns.cancelDelay("w-[160px] text-right"), ]; export const mobileColumns = [ - { - name: "Account", - className: "text-left", - render: (item) => , - }, - { - name: "Group Index", - className: "text-right", - render: (item) => ( - #{item.index} - ), - }, - - { - name: "Priority", - className: "text-right", - render: (item) => ( - - {item.inheritancePriority} - - ), - }, - { - name: "Friends", - className: "text-left", - render: (item) => , - }, - - { - name: "Threshold", - className: "text-right", - render: (item) => ( - - {item.friendsNeeded} - - ), - }, - { - name: "Inheritor", - className: "text-right", - render: (item) => , - }, - { - name: "Inheritance Delay", - className: "text-right", - render: (item) => , - }, - { - name: "Cancel Delay", - className: "text-right", - render: (item) => , - }, + friendGroupColumns.account("text-left"), + friendGroupColumns.groupIndex("text-right"), + friendGroupColumns.priority("text-right"), + friendGroupColumns.friends("text-left"), + friendGroupColumns.threshold("text-right"), + friendGroupColumns.inheritor("text-right"), + friendGroupColumns.inheritanceDelay("text-right"), + friendGroupColumns.cancelDelay("text-right"), ]; diff --git a/packages/next-common/components/data/recovery/table/index.jsx b/packages/next-common/components/data/recovery/table/index.jsx index d2e4981591..cb0a4e63e8 100644 --- a/packages/next-common/components/data/recovery/table/index.jsx +++ b/packages/next-common/components/data/recovery/table/index.jsx @@ -11,7 +11,7 @@ import { useNavCollapsed } from "next-common/context/nav"; import { cn } from "next-common/utils"; import { isNil } from "lodash-es"; import { addRouterQuery } from "next-common/utils/router"; -import { flattenRecoveryData } from "../../recovery/hooks/useQueryAllRecoveryData"; +import { flattenRecoveryData } from "../../recovery/hooks/useQueryAllFriendGroups"; export function searchAddress(list, keyword) { if (!keyword) { diff --git a/packages/next-common/components/data/recovery/table/inheritorsColumns.jsx b/packages/next-common/components/data/recovery/table/inheritorsColumns.jsx index 2bdcca48e7..9d07da3e16 100644 --- a/packages/next-common/components/data/recovery/table/inheritorsColumns.jsx +++ b/packages/next-common/components/data/recovery/table/inheritorsColumns.jsx @@ -1,83 +1,17 @@ -import AddressUser from "next-common/components/user/addressUser"; -import ValueDisplay from "next-common/components/valueDisplay"; -import { toPrecision } from "next-common/utils"; -import { useChainSettings } from "next-common/context/chain"; -import { isNil } from "lodash-es"; - -function TicketCell({ ticket }) { - const { decimals, symbol } = useChainSettings(); - if (isNil(ticket)) { - return null; - } - return ; -} +import { inheritorColumns } from "next-common/components/recovery/common/columns"; export const desktopColumns = [ - { - name: "Lost Account", - className: "min-w-[200px] text-left", - render: (item) => ( - - ), - }, - { - name: "Inheritor", - className: "min-w-[200px] text-left", - render: (item) => ( - - ), - }, - { - name: "Priority", - className: "w-[120px] text-left", - render: (item) => ( - - {item.inheritancePriority} - - ), - }, - { - name: "Depositor", - className: "min-w-[200px] text-left", - render: (item) => ( - - ), - }, - { - name: "Deposit", - className: "w-[180px] text-right", - render: (item) => , - }, + inheritorColumns.lostAccount("min-w-[200px] text-left"), + inheritorColumns.inheritor("min-w-[200px] text-left"), + inheritorColumns.priority("w-[120px] text-left"), + inheritorColumns.depositor("min-w-[200px] text-left"), + inheritorColumns.deposit("w-[180px] text-right"), ]; export const mobileColumns = [ - { - name: "Lost Account", - className: "text-left", - render: (item) => , - }, - { - name: "Inheritor", - className: "text-left", - render: (item) => , - }, - { - name: "Priority", - className: "text-right", - render: (item) => ( - - {item.inheritancePriority} - - ), - }, - { - name: "Depositor", - className: "text-left", - render: (item) => , - }, - { - name: "Deposit", - className: "text-right", - render: (item) => , - }, + inheritorColumns.lostAccount("text-left"), + inheritorColumns.inheritor("text-left"), + inheritorColumns.priority("text-right"), + inheritorColumns.depositor("text-left"), + inheritorColumns.deposit("text-right"), ]; diff --git a/packages/next-common/components/overview/account/subTabs.js b/packages/next-common/components/overview/account/subTabs.js index 3d3e75560f..8db358e014 100644 --- a/packages/next-common/components/overview/account/subTabs.js +++ b/packages/next-common/components/overview/account/subTabs.js @@ -17,7 +17,7 @@ function TabTitle({ active, children }) { export default function AccountSubTabs({ className = "" }) { const { hasMultisig, - modules: { proxy }, + modules: { proxy, recovery }, } = useChainSettings(); const chain = useChain(); @@ -76,5 +76,13 @@ export default function AccountSubTabs({ className = "" }) { }); } + if (recovery) { + tabs.push({ + value: "recovery", + label: ({ active }) => Recovery, + url: "/account/my-recovery", + }); + } + return ; } diff --git a/packages/next-common/components/overview/accountInfo/accountInfoPanel.js b/packages/next-common/components/overview/accountInfo/accountInfoPanel.js index 0a96997004..041693ef09 100644 --- a/packages/next-common/components/overview/accountInfo/accountInfoPanel.js +++ b/packages/next-common/components/overview/accountInfo/accountInfoPanel.js @@ -13,6 +13,8 @@ import { NeutralPanel } from "next-common/components/styled/containers/neutralPa import { tryConvertToEvmAddress } from "next-common/utils/mixedChainUtil"; import AccountPanelScrollPrompt from "./components/accountPanelScrollPrompt"; import ExtensionUpdatePrompt from "./components/extensionUpdatePrompt"; +import OngoingRecoveryAttemptsPrompt from "./components/useOngoingRecoveryAttemptsPrompt"; +import RecoveryInheritorPrompt from "./components/useRecoveryInheritorPrompt"; import { useAccountTransferPopup } from "./hook/useAccountTransferPopup"; import dynamic from "next/dynamic"; import { useState } from "react"; @@ -304,7 +306,7 @@ export function AccountHead({ width }) { } export default function AccountInfoPanel() { - const { hasIdentityVerification } = useChainSettings(); + const { hasIdentityVerification, modules } = useChainSettings(); const width = useWindowWidthContext(); if (isNil(width)) { @@ -321,6 +323,12 @@ export default function AccountInfoPanel() { {hasIdentityVerification && } + {modules?.recovery && ( + + + + + )} ); diff --git a/packages/next-common/components/overview/accountInfo/components/useOngoingRecoveryAttemptsPrompt.jsx b/packages/next-common/components/overview/accountInfo/components/useOngoingRecoveryAttemptsPrompt.jsx new file mode 100644 index 0000000000..9ee7687233 --- /dev/null +++ b/packages/next-common/components/overview/accountInfo/components/useOngoingRecoveryAttemptsPrompt.jsx @@ -0,0 +1,60 @@ +import { + PromptTypes, + ScrollPromptItemWrapper, +} from "next-common/components/scrollPrompt"; +import { CACHE_KEY } from "next-common/utils/constants"; +import { useCookieValue } from "next-common/utils/hooks/useCookieValue"; +import Link from "next-common/components/link"; +import { useMemo } from "react"; +import useRealAddress from "next-common/utils/hooks/useRealAddress"; +import useMyRecoveryAttempts from "next-common/components/recovery/myRecovery/hooks/useMyRecoveryAttempts"; +import { isEmpty } from "lodash-es"; + +function useOngoingRecoveryAttemptsPrompt() { + const address = useRealAddress(); + const { data, loading } = useMyRecoveryAttempts(address); + const [visible, setVisible] = useCookieValue( + CACHE_KEY.ongoingRecoveryAttemptsPrompt, + true, + ); + const hasAttempts = !loading && data?.length > 0; + + return useMemo(() => { + if (!hasAttempts || !visible) { + return {}; + } + + return { + key: CACHE_KEY.ongoingRecoveryAttemptsPrompt, + message: ( +
+ You have ongoing recovery attempts.{" "} + + See details + +
+ ), + type: PromptTypes.WARNING, + close: () => setVisible(false, { expires: 15 }), + }; + }, [hasAttempts, visible, setVisible]); +} + +export default function OngoingRecoveryAttemptsPrompt({ onClose }) { + const prompt = useOngoingRecoveryAttemptsPrompt(); + if (isEmpty(prompt)) { + return null; + } + + return ( + { + onClose?.(); + prompt?.close(); + }, + }} + /> + ); +} diff --git a/packages/next-common/components/overview/accountInfo/components/useRecoveryInheritorPrompt.jsx b/packages/next-common/components/overview/accountInfo/components/useRecoveryInheritorPrompt.jsx new file mode 100644 index 0000000000..02d04a710e --- /dev/null +++ b/packages/next-common/components/overview/accountInfo/components/useRecoveryInheritorPrompt.jsx @@ -0,0 +1,68 @@ +import { + PromptTypes, + ScrollPromptItemWrapper, +} from "next-common/components/scrollPrompt"; +import { CACHE_KEY } from "next-common/utils/constants"; +import { useCookieValue } from "next-common/utils/hooks/useCookieValue"; +import Link from "next-common/components/link"; +import AddressUser from "next-common/components/user/addressUser"; +import { useMemo } from "react"; +import useRealAddress from "next-common/utils/hooks/useRealAddress"; +import useMyInheritor from "next-common/components/recovery/myRecovery/hooks/useMyInheritor"; +import { isEmpty } from "lodash-es"; + +function useRecoveryInheritorPrompt() { + const address = useRealAddress(); + const { data, loading } = useMyInheritor(address); + const [visible, setVisible] = useCookieValue( + CACHE_KEY.recoveryInheritorPrompt, + true, + ); + const hasInheritor = !loading && data?.length > 0; + const inheritorAddress = data?.[0]?.inheritor; + + return useMemo(() => { + if (!hasInheritor || !visible) { + return {}; + } + + return { + key: CACHE_KEY.recoveryInheritorPrompt, + message: ( +
+ Your account has been inherited by + + .{" "} + + See details + +
+ ), + type: PromptTypes.WARNING, + close: () => setVisible(false, { expires: 15 }), + }; + }, [hasInheritor, visible, setVisible, inheritorAddress]); +} + +export default function RecoveryInheritorPrompt({ onClose }) { + const prompt = useRecoveryInheritorPrompt(); + if (isEmpty(prompt)) { + return null; + } + + return ( + { + onClose?.(); + prompt?.close(); + }, + }} + /> + ); +} diff --git a/packages/next-common/components/popup/fields/addressDisplay.jsx b/packages/next-common/components/popup/fields/addressDisplay.jsx new file mode 100644 index 0000000000..23fdbcae88 --- /dev/null +++ b/packages/next-common/components/popup/fields/addressDisplay.jsx @@ -0,0 +1,74 @@ +"use client"; + +import AddressAvatar from "next-common/components/user/addressAvatar"; +import useIdentityInfo from "next-common/hooks/useIdentityInfo"; +import { normalizeAddress } from "next-common/utils/address"; +import { tryConvertToEvmAddress } from "next-common/utils/mixedChainUtil"; +import { addressEllipsis } from "next-common/utils"; +import Identity from "next-common/components/Identity"; +import AddressInfoLoading from "next-common/components/addressInfo"; +import { GreyPanel } from "next-common/components/styled/containers/greyPanel"; +import styled from "styled-components"; + +const AvatarWrapper = styled.div` + display: flex; + align-items: center; + position: relative; +`; + +const NameWrapper = styled.div` + color: var(--textPrimary); + flex-grow: 1; + > :first-child { + font-size: 14px; + font-weight: 500; + } + > :last-child { + margin-top: 4px; + font-size: 12px; + color: var(--textTertiary); + } +`; + +const Wrapper = styled(GreyPanel)` + padding: 12px 16px; + gap: 16px; +`; + +export default function AddressDisplay({ address }) { + const { identity, hasIdentity, isLoading } = useIdentityInfo(address); + const normalized = normalizeAddress(address); + const displayAddress = tryConvertToEvmAddress(normalized); + const addressHint = addressEllipsis(displayAddress); + + if (isLoading) { + return ( + + + + ); + } + + return ( + +
+ + + + + {hasIdentity ? ( + <> + +
{normalized}
+ + ) : ( + <> +
{addressHint}
+
{normalized}
+ + )} +
+
+
+ ); +} diff --git a/packages/next-common/components/recovery/common/columns.jsx b/packages/next-common/components/recovery/common/columns.jsx new file mode 100644 index 0000000000..99d7e82764 --- /dev/null +++ b/packages/next-common/components/recovery/common/columns.jsx @@ -0,0 +1,237 @@ +"use client"; + +import AddressUser from "next-common/components/user/addressUser"; +import Tooltip from "next-common/components/tooltip"; +import { AddressesTooltip } from "next-common/components/multisigs/fields"; +import BlockNumberWithTooltip from "next-common/components/recovery/myRecovery/blockNumberWithTooltip"; +import DelayBlock from "next-common/components/recovery/delayBlock"; +import ValueDisplay from "next-common/components/valueDisplay"; +import { toPrecision } from "next-common/utils"; +import { useChainSettings } from "next-common/context/chain"; +import { isNil } from "lodash-es"; + +// +// Shared components +// + +export function FriendsCount({ friends = [] }) { + if (isNil(friends)) { + return null; + } + + return ( + } + > + + {friends?.length || 0} + + + ); +} + +function TicketCellInner({ ticket }) { + const { decimals, symbol } = useChainSettings(); + if (isNil(ticket)) { + return null; + } + return ; +} + +// +// Attempt table columns +// + +export const attemptColumns = { + lostAccount: (className) => ({ + name: "Lost Account", + className, + render: (item) => ( + + ), + }), + + groupIndex: (className) => ({ + name: "Group Index", + className, + render: (item) => ( + + ) + } + > + + #{item.friendGroupIndex} + + + ), + }), + + initiator: (className) => ({ + name: "Initiator", + className, + render: (item) => ( + + ), + }), + + initBlock: (className) => ({ + name: "Init Block", + className, + render: (item) => , + }), + + lastApprovalBlock: (className) => ({ + name: "Last Approval Block", + className, + render: (item) => ( + + ), + }), + + thresholdApprovals: (className) => ({ + name: "Approvals / Threshold", + className, + render: (item) => ( + 0 && ( + + ) + } + > + + {item.approvalsCount} + {item.fgGroup && ( + + {" "} + / {item.fgGroup?.friendsNeeded || 0} + + )} + + + ), + }), +}; + +// +// Friend group table columns +// + +export const friendGroupColumns = { + account: (className) => ({ + name: "Lost Account", + className, + render: (item) => ( + + ), + }), + + groupIndex: (className) => ({ + name: "Group Index", + className, + render: (item) => ( + #{item.index} + ), + }), + + priority: (className) => ({ + name: "Priority", + className, + render: (item) => ( + + {item.inheritancePriority} + + ), + }), + + friends: (className) => ({ + name: "Friends", + className, + render: (item) => , + }), + + threshold: (className) => ({ + name: "Threshold", + className, + render: (item) => ( + + {item.friendsNeeded} + + ), + }), + + inheritor: (className) => ({ + name: "Inheritor", + className, + render: (item) => ( + + ), + }), + + inheritanceDelay: (className) => ({ + name: "Inheritance Delay", + className, + render: (item) => , + }), + + cancelDelay: (className) => ({ + name: "Cancel Delay", + className, + render: (item) => , + }), +}; + +// +// Inheritor table columns +// + +export const inheritorColumns = { + lostAccount: (className) => ({ + name: "Lost Account", + className, + render: (item) => ( + + ), + }), + + inheritor: (className) => ({ + name: "Inheritor", + className, + render: (item) => ( + + ), + }), + + priority: (className) => ({ + name: "Priority", + className, + render: (item) => ( + + {item.inheritancePriority} + + ), + }), + + depositor: (className) => ({ + name: "Depositor", + className, + render: (item) => ( + + ), + }), + + deposit: (className) => ({ + name: "Deposit", + className, + render: (item) => , + }), +}; diff --git a/packages/next-common/components/recovery/delayBlock.jsx b/packages/next-common/components/recovery/delayBlock.jsx new file mode 100644 index 0000000000..f40f7d52f0 --- /dev/null +++ b/packages/next-common/components/recovery/delayBlock.jsx @@ -0,0 +1,19 @@ +import Tooltip from "next-common/components/tooltip"; +import { useEstimateBlocksTime } from "next-common/utils/hooks"; +import { isNil } from "lodash-es"; + +export default function DelayBlock({ blocks }) { + const estimatedTime = useEstimateBlocksTime(blocks); + + if (isNil(blocks)) { + return null; + } + + return ( + + + {blocks?.toLocaleString() || 0} + + + ); +} diff --git a/packages/next-common/components/recovery/helpOthers/approveAttemptDialog.jsx b/packages/next-common/components/recovery/helpOthers/approveAttemptDialog.jsx new file mode 100644 index 0000000000..b8b10a5e7c --- /dev/null +++ b/packages/next-common/components/recovery/helpOthers/approveAttemptDialog.jsx @@ -0,0 +1,27 @@ +"use client"; + +import { useCallback } from "react"; +import SimpleTxPopup from "next-common/components/simpleTxPopup"; +import { useContextApi } from "next-common/context/api"; + +export default function ApproveAttemptDialog({ + onClose, + lostAccount, + friendGroupIndex, + onInBlock = () => {}, +}) { + const api = useContextApi(); + + const getTxFunc = useCallback(async () => { + return api.tx.recovery.approveAttempt(lostAccount, friendGroupIndex); + }, [api, lostAccount, friendGroupIndex]); + + return ( + + ); +} diff --git a/packages/next-common/components/recovery/helpOthers/cancelAttemptDialog.jsx b/packages/next-common/components/recovery/helpOthers/cancelAttemptDialog.jsx new file mode 100644 index 0000000000..12c6343ba1 --- /dev/null +++ b/packages/next-common/components/recovery/helpOthers/cancelAttemptDialog.jsx @@ -0,0 +1,27 @@ +"use client"; + +import { useCallback } from "react"; +import SimpleTxPopup from "next-common/components/simpleTxPopup"; +import { useContextApi } from "next-common/context/api"; + +export default function CancelAttemptDialog({ + onClose, + lostAccount, + friendGroupIndex, + onInBlock = () => {}, +}) { + const api = useContextApi(); + + const getTxFunc = useCallback(async () => { + return api.tx.recovery.cancelAttempt(lostAccount, friendGroupIndex); + }, [api, lostAccount, friendGroupIndex]); + + return ( + + ); +} diff --git a/packages/next-common/components/recovery/helpOthers/context.js b/packages/next-common/components/recovery/helpOthers/context.js new file mode 100644 index 0000000000..1ead26d931 --- /dev/null +++ b/packages/next-common/components/recovery/helpOthers/context.js @@ -0,0 +1,55 @@ +"use client"; + +import { createContext, useContext, useMemo } from "react"; +import useHelpOthersFriendGroups from "./hooks/useHelpOthersFriendGroups"; +import useHelpOthersAttempts from "./hooks/useHelpOthersAttempts"; + +const HelpOthersDataContext = createContext(null); + +export function HelpOthersDataProvider({ address, children }) { + const friendGroups = useHelpOthersFriendGroups(address); + const attempts = useHelpOthersAttempts(address); + + const loading = friendGroups.loading || attempts.loading; + + const value = useMemo( + () => ({ + address, + friendGroupsData: friendGroups.data, + friendGroupsLoading: friendGroups.loading, + fetchFriendGroups: friendGroups.fetch, + attemptsData: attempts.data, + attemptsLoading: attempts.loading, + attemptsFriendGroupsData: attempts.friendGroupsData, + fetchAttempts: attempts.fetch, + loading, + }), + [ + address, + friendGroups.data, + friendGroups.loading, + friendGroups.fetch, + attempts.data, + attempts.loading, + attempts.friendGroupsData, + attempts.fetch, + loading, + ], + ); + + return ( + + {children} + + ); +} + +export function useHelpOthersData() { + const context = useContext(HelpOthersDataContext); + if (!context) { + throw new Error( + "useHelpOthersData must be used within a HelpOthersDataProvider", + ); + } + return context; +} diff --git a/packages/next-common/components/recovery/helpOthers/finishAttemptDialog.jsx b/packages/next-common/components/recovery/helpOthers/finishAttemptDialog.jsx new file mode 100644 index 0000000000..f7180c7c71 --- /dev/null +++ b/packages/next-common/components/recovery/helpOthers/finishAttemptDialog.jsx @@ -0,0 +1,27 @@ +"use client"; + +import { useCallback } from "react"; +import SimpleTxPopup from "next-common/components/simpleTxPopup"; +import { useContextApi } from "next-common/context/api"; + +export default function FinishAttemptDialog({ + onClose, + lostAccount, + friendGroupIndex, + onInBlock = () => {}, +}) { + const api = useContextApi(); + + const getTxFunc = useCallback(async () => { + return api.tx.recovery.finishAttempt(lostAccount, friendGroupIndex); + }, [api, lostAccount, friendGroupIndex]); + + return ( + + ); +} diff --git a/packages/next-common/components/recovery/helpOthers/helpOthersAttemptsSection.jsx b/packages/next-common/components/recovery/helpOthers/helpOthersAttemptsSection.jsx new file mode 100644 index 0000000000..ca18de6556 --- /dev/null +++ b/packages/next-common/components/recovery/helpOthers/helpOthersAttemptsSection.jsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { SecondaryCard } from "next-common/components/styled/containers/secondaryCard"; +import { MapDataList } from "next-common/components/dataList"; +import ScrollerX from "next-common/components/styled/containers/scrollerX"; +import usePaginationComponent from "next-common/components/pagination/usePaginationComponent"; +import { defaultPageSize } from "next-common/utils/constants"; +import { useNavCollapsed } from "next-common/context/nav"; +import { cn } from "next-common/utils"; +import { isNil } from "lodash-es"; +import useHelpOthersAttemptsColumns from "./hooks/useHelpOthersAttemptsColumns"; + +function enhanceAttemptWithFriendGroup(attempt, friendGroups = []) { + const fgList = friendGroups?.find((fg) => fg.account === attempt.lostAccount); + const fgGroup = fgList?.friendGroups?.[attempt.friendGroupIndex]; + return { ...attempt, fgGroup }; +} + +export default function HelpOthersAttemptsSection({ + address, + data: rawData, + loading: isLoading, + friendGroupsData, + onRefresh, +}) { + const { desktopColumns, mobileColumns } = useHelpOthersAttemptsColumns( + address, + onRefresh, + ); + const [navCollapsed] = useNavCollapsed(); + const [dataList, setDataList] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(true); + + const { page, component: pageComponent } = usePaginationComponent( + totalCount, + defaultPageSize, + ); + + const total = rawData?.length || 0; + + useEffect(() => { + setLoading(true); + }, [page]); + + useEffect(() => { + if (isLoading || isNil(rawData)) { + return; + } + + const enhanced = rawData.map((attempt) => + enhanceAttemptWithFriendGroup(attempt, friendGroupsData), + ); + + setTotalCount(total); + const startIndex = (page - 1) * defaultPageSize; + const endIndex = startIndex + defaultPageSize; + setDataList(enhanced?.slice(startIndex, endIndex)); + setLoading(false); + }, [rawData, isLoading, page, total, friendGroupsData]); + + return ( +
+
+ + Ongoing Recovery Attempts + +
+
+ + + + + + {total > 0 && pageComponent} + +
+
+ ); +} diff --git a/packages/next-common/components/recovery/helpOthers/hooks/useHelpOthersAttempts.js b/packages/next-common/components/recovery/helpOthers/hooks/useHelpOthersAttempts.js new file mode 100644 index 0000000000..048f37c91b --- /dev/null +++ b/packages/next-common/components/recovery/helpOthers/hooks/useHelpOthersAttempts.js @@ -0,0 +1,33 @@ +import { useMemo } from "react"; +import useQueryAllRecoveryAttempts, { + buildFriendGroupsData, +} from "next-common/components/data/recovery/hooks/useQueryAllRecoveryAttempts"; + +export default function useHelpOthersAttempts(address) { + const { + data: attempts, + loading, + fetch, + friendGroupsMap, + } = useQueryAllRecoveryAttempts(); + + const data = useMemo(() => { + if (!address) return []; + + return attempts.filter((attempt) => { + const friendGroups = friendGroupsMap[attempt.lostAccount] || []; + return friendGroups.some((group) => + (group.friends || []).some( + (f) => f?.toLowerCase() === address?.toLowerCase(), + ), + ); + }); + }, [attempts, friendGroupsMap, address]); + + const friendGroupsData = useMemo( + () => buildFriendGroupsData(friendGroupsMap), + [friendGroupsMap], + ); + + return { data, loading, friendGroupsData, fetch }; +} diff --git a/packages/next-common/components/recovery/helpOthers/hooks/useHelpOthersAttemptsColumns.jsx b/packages/next-common/components/recovery/helpOthers/hooks/useHelpOthersAttemptsColumns.jsx new file mode 100644 index 0000000000..15919e4348 --- /dev/null +++ b/packages/next-common/components/recovery/helpOthers/hooks/useHelpOthersAttemptsColumns.jsx @@ -0,0 +1,268 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Tooltip from "next-common/components/tooltip"; +import { attemptColumns } from "next-common/components/recovery/common/columns"; +import { isSameAddress, estimateBlocksTime } from "next-common/utils"; +import { isNil } from "lodash-es"; +import { useChainSettings } from "next-common/context/chain"; +import useRelayChainBlockNumber from "next-common/hooks/useRelayChainBlockNumber"; +import CancelAttemptDialog from "../cancelAttemptDialog"; +import ApproveAttemptDialog from "../approveAttemptDialog"; +import FinishAttemptDialog from "../finishAttemptDialog"; + +function CancelButton({ + lostAccount, + friendGroupIndex, + lastApprovalBlock, + cancelDelay, + onCancel, +}) { + const [showDialog, setShowDialog] = useState(false); + const currentBlock = useRelayChainBlockNumber(); + const { blockTime } = useChainSettings(); + + const cancelableAt = lastApprovalBlock + (cancelDelay || 0); + const canCancel = !isNil(currentBlock) && currentBlock >= cancelableAt; + + if (!canCancel) { + let tooltip; + if (isNil(currentBlock)) { + tooltip = "Loading block number..."; + } else { + const blocksRemaining = Math.max(0, cancelableAt - currentBlock); + const estimatedTime = estimateBlocksTime(blocksRemaining, blockTime); + tooltip = `Cancel available at block #${cancelableAt} (in ${estimatedTime})`; + } + return ( + + + Cancel + + + ); + } + + return ( + <> + {showDialog && ( + setShowDialog(false)} + lostAccount={lostAccount} + friendGroupIndex={friendGroupIndex} + onInBlock={onCancel} + /> + )} + + + + + ); +} + +function ApproveButton({ + lostAccount, + friendGroupIndex, + onApprove, + alreadyApproved, +}) { + const [showDialog, setShowDialog] = useState(false); + + if (alreadyApproved) { + return ( + + + Approve + + + ); + } + + return ( + <> + {showDialog && ( + setShowDialog(false)} + lostAccount={lostAccount} + friendGroupIndex={friendGroupIndex} + onInBlock={onApprove} + /> + )} + + + + + ); +} + +function FinishButton({ + lostAccount, + friendGroupIndex, + initBlock, + inheritanceDelay, + onFinish, +}) { + const [showDialog, setShowDialog] = useState(false); + const currentBlock = useRelayChainBlockNumber(); + const { blockTime } = useChainSettings(); + + const finishBlock = initBlock + (inheritanceDelay || 0); + const canFinish = !isNil(currentBlock) && currentBlock >= finishBlock; + + if (!canFinish) { + let tooltip; + if (isNil(currentBlock)) { + tooltip = "Loading block number..."; + } else { + const blocksRemaining = Math.max(0, finishBlock - currentBlock); + const estimatedTime = estimateBlocksTime(blocksRemaining, blockTime); + tooltip = `Finish available at block #${finishBlock} (in ${estimatedTime})`; + } + return ( + + + Finish + + + ); + } + + return ( + <> + {showDialog && ( + setShowDialog(false)} + lostAccount={lostAccount} + friendGroupIndex={friendGroupIndex} + onInBlock={onFinish} + /> + )} + + + + + ); +} + +function HelpOthersAttemptActions({ + lostAccount, + friendGroupIndex, + initBlock, + lastApprovalBlock, + initiator, + approvalsCount, + approvedAddresses, + fgGroup, + friendsNeeded, + address, + onAction, +}) { + const threshold = fgGroup?.friendsNeeded || friendsNeeded || 0; + const canFinish = (approvalsCount || 0) >= threshold; + + if (canFinish) { + return ( +
+ + {isSameAddress(initiator, address) && ( + + )} +
+ ); + } + + return isSameAddress(initiator, address) ? ( + + ) : ( + + isSameAddress(a, address), + )} + /> + ); +} + +export default function useHelpOthersAttemptsColumns(address, onAction) { + return useMemo(() => { + const desktopColumns = [ + attemptColumns.lostAccount("min-w-[200px] text-left"), + attemptColumns.groupIndex("w-[120px] text-left"), + attemptColumns.initiator("min-w-[160px] text-left"), + attemptColumns.initBlock("w-[140px] text-left"), + attemptColumns.lastApprovalBlock("w-[180px] text-left"), + attemptColumns.thresholdApprovals("w-[160px] text-left"), + { + name: "Action", + className: "w-[100px] text-right", + render: (item) => ( + + ), + }, + ]; + + const mobileColumns = [ + attemptColumns.lostAccount("text-left"), + attemptColumns.groupIndex("text-right"), + attemptColumns.initiator("text-left"), + attemptColumns.initBlock("text-right"), + attemptColumns.lastApprovalBlock("text-right"), + attemptColumns.thresholdApprovals("text-right"), + { + name: "Action", + className: "text-left", + render: (item) => ( + + ), + }, + ]; + + return { desktopColumns, mobileColumns }; + }, [address, onAction]); +} diff --git a/packages/next-common/components/recovery/helpOthers/hooks/useHelpOthersFriendGroups.js b/packages/next-common/components/recovery/helpOthers/hooks/useHelpOthersFriendGroups.js new file mode 100644 index 0000000000..ffdc1b6464 --- /dev/null +++ b/packages/next-common/components/recovery/helpOthers/hooks/useHelpOthersFriendGroups.js @@ -0,0 +1,22 @@ +import { useMemo } from "react"; +import useQueryAllFriendGroups, { + flattenRecoveryData, +} from "next-common/components/data/recovery/hooks/useQueryAllFriendGroups"; + +export default function useHelpOthersFriendGroups(address) { + const { data: allData, loading, fetch } = useQueryAllFriendGroups(); + + const data = useMemo(() => { + if (!address) return []; + + const filtered = allData.filter((entry) => + entry.friendGroups.some((g) => + g.friends.some((f) => f?.toLowerCase() === address?.toLowerCase()), + ), + ); + + return flattenRecoveryData(filtered); + }, [allData, address]); + + return { data, loading, fetch }; +} diff --git a/packages/next-common/components/recovery/helpOthers/hooks/useHelpOthersFriendGroupsColumns.jsx b/packages/next-common/components/recovery/helpOthers/hooks/useHelpOthersFriendGroupsColumns.jsx new file mode 100644 index 0000000000..8e52437bd1 --- /dev/null +++ b/packages/next-common/components/recovery/helpOthers/hooks/useHelpOthersFriendGroupsColumns.jsx @@ -0,0 +1,117 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Tooltip from "next-common/components/tooltip"; +import { friendGroupColumns } from "next-common/components/recovery/common/columns"; +import InitiateAttemptDialog from "../initiateAttemptDialog"; + +function RecoverButton({ lostAccount, friendGroupIndex, onRecover, disabled }) { + const [showDialog, setShowDialog] = useState(false); + + if (disabled) { + return ( + + + Recover + + + ); + } + + return ( + <> + {showDialog && ( + setShowDialog(false)} + lostAccount={lostAccount} + friendGroupIndex={friendGroupIndex} + onInBlock={onRecover} + /> + )} + + + + + ); +} + +function HelpOthersFriendGroupActions({ + account, + index, + attemptsData, + onRecover, +}) { + const hasAttempt = attemptsData.some( + (a) => a.lostAccount === account && a.friendGroupIndex === index, + ); + + return ( + + ); +} + +export default function useHelpOthersFriendGroupsColumns( + onRecover, + attemptsData = [], +) { + return useMemo(() => { + const desktopColumns = [ + friendGroupColumns.account("min-w-[200px] text-left"), + friendGroupColumns.groupIndex("w-[120px] text-left"), + friendGroupColumns.priority("w-[100px] text-left"), + friendGroupColumns.friends("w-[100px] text-left"), + friendGroupColumns.threshold("w-[120px] text-left"), + friendGroupColumns.inheritor("min-w-[160px] text-left"), + friendGroupColumns.inheritanceDelay("w-[180px] text-left"), + friendGroupColumns.cancelDelay("w-[160px] text-left"), + { + name: "Action", + className: "w-[100px] text-right", + render: (item) => ( + + ), + }, + ]; + + const mobileColumns = [ + friendGroupColumns.account("text-left"), + friendGroupColumns.groupIndex("text-right"), + friendGroupColumns.priority("text-right"), + friendGroupColumns.friends("text-left"), + friendGroupColumns.threshold("text-right"), + friendGroupColumns.inheritor("text-right"), + friendGroupColumns.inheritanceDelay("text-right"), + friendGroupColumns.cancelDelay("text-right"), + { + name: "Action", + className: "text-left", + render: (item) => ( + + ), + }, + ]; + + return { desktopColumns, mobileColumns }; + }, [onRecover, attemptsData]); +} diff --git a/packages/next-common/components/recovery/helpOthers/inFriendGroupsSection.jsx b/packages/next-common/components/recovery/helpOthers/inFriendGroupsSection.jsx new file mode 100644 index 0000000000..2d4288735f --- /dev/null +++ b/packages/next-common/components/recovery/helpOthers/inFriendGroupsSection.jsx @@ -0,0 +1,88 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { SecondaryCard } from "next-common/components/styled/containers/secondaryCard"; +import { MapDataList } from "next-common/components/dataList"; +import ScrollerX from "next-common/components/styled/containers/scrollerX"; +import usePaginationComponent from "next-common/components/pagination/usePaginationComponent"; +import { defaultPageSize } from "next-common/utils/constants"; +import { useNavCollapsed } from "next-common/context/nav"; +import { cn } from "next-common/utils"; +import { isNil } from "lodash-es"; +import useHelpOthersFriendGroupsColumns from "./hooks/useHelpOthersFriendGroupsColumns"; + +export default function InFriendGroupsSection({ + data, + loading: isLoading, + attemptsData = [], + onRefresh, +}) { + const { desktopColumns, mobileColumns } = useHelpOthersFriendGroupsColumns( + onRefresh, + attemptsData, + ); + const [navCollapsed] = useNavCollapsed(); + const [dataList, setDataList] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(true); + + const { page, component: pageComponent } = usePaginationComponent( + totalCount, + defaultPageSize, + ); + + const total = data?.length || 0; + + useEffect(() => { + setLoading(true); + }, [page]); + + useEffect(() => { + if (isLoading || isNil(data)) { + return; + } + + setTotalCount(total); + const startIndex = (page - 1) * defaultPageSize; + const endIndex = startIndex + defaultPageSize; + setDataList(data?.slice(startIndex, endIndex)); + setLoading(false); + }, [data, isLoading, page, total]); + + return ( +
+
+ + Friend Groups + + I'm a friend + + +
+
+ + + + + + {total > 0 && pageComponent} + +
+
+ ); +} diff --git a/packages/next-common/components/recovery/helpOthers/index.jsx b/packages/next-common/components/recovery/helpOthers/index.jsx new file mode 100644 index 0000000000..74b66bc129 --- /dev/null +++ b/packages/next-common/components/recovery/helpOthers/index.jsx @@ -0,0 +1,95 @@ +"use client"; + +import { useCallback } from "react"; +import useRealAddress from "next-common/utils/hooks/useRealAddress"; +import RecoverySubTabs from "next-common/components/recovery/subTabs"; +import InFriendGroupsSection from "./inFriendGroupsSection"; +import HelpOthersAttemptsSection from "./helpOthersAttemptsSection"; +import { RelayChainApiProvider } from "next-common/context/relayChain"; +import { HelpOthersDataProvider, useHelpOthersData } from "./context"; +import Loading from "next-common/components/loading"; + +function HelpOthersSections() { + const { + address, + friendGroupsData, + friendGroupsLoading, + fetchFriendGroups, + attemptsData, + attemptsLoading, + attemptsFriendGroupsData, + fetchAttempts, + loading, + } = useHelpOthersData(); + + const refreshAll = useCallback(() => { + fetchFriendGroups(); + fetchAttempts(); + }, [fetchFriendGroups, fetchAttempts]); + + if (loading) { + return ( +
+ +
+ ); + } + + const hasData = (arr) => arr && arr.length > 0; + + const sectionConfigs = [ + { + key: "attempts", + hasData: hasData(attemptsData), + Component: HelpOthersAttemptsSection, + props: { + address, + data: attemptsData, + loading: attemptsLoading, + friendGroupsData: attemptsFriendGroupsData, + onRefresh: refreshAll, + }, + }, + { + key: "friendGroups", + hasData: hasData(friendGroupsData), + Component: InFriendGroupsSection, + props: { + data: friendGroupsData, + loading: friendGroupsLoading, + attemptsData, + onRefresh: refreshAll, + }, + }, + ]; + + const sorted = [...sectionConfigs].sort((a, b) => { + if (a.hasData !== b.hasData) { + return a.hasData ? -1 : 1; + } + return 0; // preserve original order within same group + }); + + return ( + <> + {sorted.map(({ key, Component, props }) => ( + + ))} + + ); +} + +export default function HelpOthersContent() { + const address = useRealAddress(); + + return ( + + +
+ + +
+
+
+ ); +} diff --git a/packages/next-common/components/recovery/helpOthers/initiateAttemptDialog.jsx b/packages/next-common/components/recovery/helpOthers/initiateAttemptDialog.jsx new file mode 100644 index 0000000000..0eb014c0dd --- /dev/null +++ b/packages/next-common/components/recovery/helpOthers/initiateAttemptDialog.jsx @@ -0,0 +1,27 @@ +"use client"; + +import { useCallback } from "react"; +import SimpleTxPopup from "next-common/components/simpleTxPopup"; +import { useContextApi } from "next-common/context/api"; + +export default function InitiateAttemptDialog({ + onClose, + lostAccount, + friendGroupIndex, + onInBlock = () => {}, +}) { + const api = useContextApi(); + + const getTxFunc = useCallback(async () => { + return api.tx.recovery.initiateAttempt(lostAccount, friendGroupIndex); + }, [api, lostAccount, friendGroupIndex]); + + return ( + + ); +} diff --git a/packages/next-common/components/recovery/index.jsx b/packages/next-common/components/recovery/index.jsx new file mode 100644 index 0000000000..aaac81d0d6 --- /dev/null +++ b/packages/next-common/components/recovery/index.jsx @@ -0,0 +1,82 @@ +"use client"; + +import ListLayout from "next-common/components/layout/ListLayout"; +import Tabs from "next-common/components/tabs"; +import MyRecoveryContent from "next-common/components/recovery/myRecovery"; +import { RelayChainApiProvider } from "next-common/context/relayChain"; + +const TABS = Object.freeze([ + { + value: "my_recovery", + label: "My Recovery", + url: "/recovery/my-recovery", + }, + { + value: "help_others", + label: "Help Others", + url: "/recovery/help-others", + }, + { + value: "inheritors", + label: "Inheritants", + url: "/recovery/inheritants", + }, +]); + +function HelpOthersPlaceholder() { + return ( +
+ Help Others placeholder content. View recovery requests from others where + you are a friend. +
+ ); +} + +function InheritorsPlaceholder() { + return ( +
+ Inheritants placeholder content. View recovery configurations where you + are designated as an inheritor. +
+ ); +} + +function RecoveryContent({ activeTab }) { + if (activeTab === "my_recovery") { + return ; + } else if (activeTab === "help_others") { + return ; + } else if (activeTab === "inheritors") { + return ; + } + return null; +} + +function HeaderTabs({ activeTab }) { + return ( +
+ +
+ ); +} + +export default function Recovery({ activeTab = "my_recovery" }) { + return ( + + } + > +
+ +
+
+
+ ); +} diff --git a/packages/next-common/components/recovery/inheritors/context.js b/packages/next-common/components/recovery/inheritors/context.js new file mode 100644 index 0000000000..e79e77f4d0 --- /dev/null +++ b/packages/next-common/components/recovery/inheritors/context.js @@ -0,0 +1,62 @@ +"use client"; + +import { createContext, useContext, useMemo } from "react"; +import useInheritedAccounts from "./hooks/useInheritedAccounts"; +import useInheritorFriendGroups from "./hooks/useInheritorFriendGroups"; + +const InheritorsDataContext = createContext(null); + +export function InheritorsDataProvider({ address, children }) { + const { + data: inheritedAccounts, + loading: inheritedAccountsLoading, + fetch: fetchInheritedAccounts, + } = useInheritedAccounts(address); + + const { + data: inheritorFriendGroups, + loading: inheritorFriendGroupsLoading, + fetch: fetchInheritorFriendGroups, + } = useInheritorFriendGroups(address); + + const loading = inheritedAccountsLoading || inheritorFriendGroupsLoading; + + const value = useMemo( + () => ({ + address, + inheritedAccounts, + inheritedAccountsLoading, + fetchInheritedAccounts, + inheritorFriendGroups, + inheritorFriendGroupsLoading, + fetchInheritorFriendGroups, + loading, + }), + [ + address, + inheritedAccounts, + inheritedAccountsLoading, + fetchInheritedAccounts, + inheritorFriendGroups, + inheritorFriendGroupsLoading, + fetchInheritorFriendGroups, + loading, + ], + ); + + return ( + + {children} + + ); +} + +export function useInheritorsData() { + const context = useContext(InheritorsDataContext); + if (!context) { + throw new Error( + "useInheritorsData must be used within a InheritorsDataProvider", + ); + } + return context; +} diff --git a/packages/next-common/components/recovery/inheritors/controlInheritedAccountDialog.jsx b/packages/next-common/components/recovery/inheritors/controlInheritedAccountDialog.jsx new file mode 100644 index 0000000000..553dfc15a6 --- /dev/null +++ b/packages/next-common/components/recovery/inheritors/controlInheritedAccountDialog.jsx @@ -0,0 +1,100 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useContextApi } from "next-common/context/api"; +import SignerPopupWrapper from "next-common/components/popupWithSigner/signerPopupWrapper"; +import PopupLabel from "next-common/components/popup/label"; +import Extrinsic from "next-common/components/extrinsic"; +import SignerWithBalance from "next-common/components/signerPopup/signerWithBalance"; +import TxSubmissionButton from "next-common/components/common/tx/txSubmissionButton"; +import AdvanceSettings from "next-common/components/summary/newProposalQuickStart/common/advanceSettings"; +import EstimatedGas from "next-common/components/estimatedGas"; +import { usePopupParams } from "next-common/components/popupWithSigner/context"; +import { ExtrinsicLoading } from "next-common/components/popup/fields/extrinsicField"; +import AddressDisplay from "next-common/components/popup/fields/addressDisplay"; +import Popup from "next-common/components/popup/wrapper/Popup"; + +function ControlInheritedAccountInnerPopup({ recovered, onInBlock }) { + const { onClose } = usePopupParams(); + const api = useContextApi(); + const [call, setCall] = useState(null); + const disabled = !api || !call; + + const getTxFunc = useCallback(async () => { + if (!api || !call) { + return null; + } + return api.tx.recovery.controlInheritedAccount(recovered, call); + }, [api, recovered, call]); + + const setProposal = useCallback( + ({ isValid, data: tx } = {}) => { + if (!api || !isValid || !tx) { + setCall(null); + return; + } + setCall(tx); + }, + [api], + ); + + let extrinsicComponent = null; + + if (!api) { + extrinsicComponent = ; + } else { + extrinsicComponent = ( +
+
+ + +
+
+ ); + } + + return ( + + +
+ + +
+ {extrinsicComponent} + + + + +
+ ); +} + +export default function ControlInheritedAccountDialog({ + onClose, + recovered, + onInBlock, +}) { + return ( + + + + ); +} diff --git a/packages/next-common/components/recovery/inheritors/hooks/useInheritedAccounts.js b/packages/next-common/components/recovery/inheritors/hooks/useInheritedAccounts.js new file mode 100644 index 0000000000..1c220635bb --- /dev/null +++ b/packages/next-common/components/recovery/inheritors/hooks/useInheritedAccounts.js @@ -0,0 +1,15 @@ +import { useMemo } from "react"; +import useQueryAllRecoveryInheritors from "next-common/components/data/recovery/hooks/useQueryAllRecoveryInheritors"; + +export default function useInheritedAccounts(address) { + const { data: allData, loading, fetch } = useQueryAllRecoveryInheritors(); + + const data = useMemo(() => { + if (!address) return []; + return allData.filter( + (item) => item.inheritor?.toLowerCase() === address?.toLowerCase(), + ); + }, [allData, address]); + + return { data, loading, fetch }; +} diff --git a/packages/next-common/components/recovery/inheritors/hooks/useInheritedAccountsColumns.jsx b/packages/next-common/components/recovery/inheritors/hooks/useInheritedAccountsColumns.jsx new file mode 100644 index 0000000000..abf10b4be5 --- /dev/null +++ b/packages/next-common/components/recovery/inheritors/hooks/useInheritedAccountsColumns.jsx @@ -0,0 +1,66 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Tooltip from "next-common/components/tooltip"; +import { inheritorColumns } from "next-common/components/recovery/common/columns"; +import ControlInheritedAccountDialog from "../controlInheritedAccountDialog"; + +function ControlButton({ item }) { + const [showDialog, setShowDialog] = useState(false); + + return ( + <> + {showDialog && ( + setShowDialog(false)} + /> + )} + + + + + ); +} + +function InheritedAccountActions({ item }) { + return ; +} + +export default function useInheritedAccountsColumns() { + return useMemo(() => { + const desktopColumns = [ + inheritorColumns.lostAccount("min-w-[200px] text-left"), + inheritorColumns.inheritor("min-w-[200px] text-left"), + inheritorColumns.priority("w-[120px] text-left"), + inheritorColumns.depositor("min-w-[200px] text-left"), + inheritorColumns.deposit("w-[180px] text-left"), + { + name: "Action", + className: "w-[100px] text-right", + render: (item) => , + }, + ]; + + const mobileColumns = [ + inheritorColumns.lostAccount("text-left"), + inheritorColumns.inheritor("text-left"), + inheritorColumns.priority("text-right"), + inheritorColumns.depositor("text-left"), + inheritorColumns.deposit("text-right"), + { + name: "Action", + className: "text-left", + render: (item) => , + }, + ]; + + return { desktopColumns, mobileColumns }; + }, []); +} diff --git a/packages/next-common/components/recovery/inheritors/hooks/useInheritorFriendGroups.js b/packages/next-common/components/recovery/inheritors/hooks/useInheritorFriendGroups.js new file mode 100644 index 0000000000..21050e6f2e --- /dev/null +++ b/packages/next-common/components/recovery/inheritors/hooks/useInheritorFriendGroups.js @@ -0,0 +1,82 @@ +import { useContextApi } from "next-common/context/api"; +import { useCallback, useEffect, useState } from "react"; +import { flattenRecoveryData } from "next-common/components/data/recovery/hooks/useQueryAllFriendGroups"; + +export default function useInheritorFriendGroups(address) { + const api = useContextApi(); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [fetchCount, setFetchCount] = useState(0); + + const fetch = useCallback(() => setFetchCount((c) => c + 1), []); + + useEffect(() => { + if (!api || !address) { + return; + } + + if (!api?.query.recovery?.friendGroups) { + setLoading(false); + setData([]); + return; + } + + let cancelled = false; + setLoading(true); + + api.query.recovery.friendGroups + .entries() + .then((entries) => { + if (cancelled) return; + + const result = entries.map(([storageKey, value]) => { + const account = storageKey.args?.[0]?.toString(); + const jsonValue = value.toJSON(); + const friendGroupVec = Array.isArray(jsonValue?.[0]) + ? jsonValue[0] + : []; + + return { + account, + friendGroups: friendGroupVec.map((group, index) => ({ + index, + friends: group.friends || [], + friendsNeeded: parseInt(group.friendsNeeded) || 0, + inheritor: group.inheritor || "", + inheritancePriority: parseInt(group.inheritancePriority) || 0, + inheritanceDelay: parseInt(group.inheritanceDelay) || 0, + cancelDelay: parseInt(group.cancelDelay) || 0, + })), + }; + }); + + // Filter to entries where current address is an inheritor + const filtered = result.filter((entry) => + entry.friendGroups.some( + (g) => g.inheritor?.toLowerCase() === address?.toLowerCase(), + ), + ); + + // Flatten for table display + const flattened = flattenRecoveryData(filtered); + + if (!cancelled) { + setData(flattened); + setLoading(false); + } + }) + .catch((error) => { + console.error("Failed to query inheritor friend groups", error); + if (!cancelled) { + setData([]); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [api, address, fetchCount]); + + return { data, loading, fetch }; +} diff --git a/packages/next-common/components/recovery/inheritors/index.jsx b/packages/next-common/components/recovery/inheritors/index.jsx new file mode 100644 index 0000000000..938bf0c7b6 --- /dev/null +++ b/packages/next-common/components/recovery/inheritors/index.jsx @@ -0,0 +1,67 @@ +"use client"; + +import useRealAddress from "next-common/utils/hooks/useRealAddress"; +import RecoverySubTabs from "next-common/components/recovery/subTabs"; +import { RelayChainApiProvider } from "next-common/context/relayChain"; +import { InheritorsDataProvider, useInheritorsData } from "./context"; +import InheritedAccountsSection from "./inheritedAccountsSection"; +import InheritorFriendGroupsSection from "./inheritorFriendGroupsSection"; +import Loading from "next-common/components/loading"; + +function InheritorsSections() { + const { loading, inheritedAccounts, inheritorFriendGroups } = + useInheritorsData(); + + if (loading) { + return ( +
+ +
+ ); + } + + const hasData = (arr) => arr && arr.length > 0; + + const sectionConfigs = [ + { + key: "inheritedAccounts", + hasData: hasData(inheritedAccounts), + Component: InheritedAccountsSection, + }, + { + key: "inheritorFriendGroups", + hasData: hasData(inheritorFriendGroups), + Component: InheritorFriendGroupsSection, + }, + ]; + + const sorted = [...sectionConfigs].sort((a, b) => { + if (a.hasData !== b.hasData) { + return a.hasData ? -1 : 1; + } + return 0; + }); + + return ( + <> + {sorted.map(({ key, Component }) => ( + + ))} + + ); +} + +export default function InheritorsContent() { + const address = useRealAddress(); + + return ( + + +
+ + +
+
+
+ ); +} diff --git a/packages/next-common/components/recovery/inheritors/inheritedAccountsSection.jsx b/packages/next-common/components/recovery/inheritors/inheritedAccountsSection.jsx new file mode 100644 index 0000000000..2f92fc5799 --- /dev/null +++ b/packages/next-common/components/recovery/inheritors/inheritedAccountsSection.jsx @@ -0,0 +1,82 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { SecondaryCard } from "next-common/components/styled/containers/secondaryCard"; +import { MapDataList } from "next-common/components/dataList"; +import ScrollerX from "next-common/components/styled/containers/scrollerX"; +import usePaginationComponent from "next-common/components/pagination/usePaginationComponent"; +import { defaultPageSize } from "next-common/utils/constants"; +import { useNavCollapsed } from "next-common/context/nav"; +import { cn } from "next-common/utils"; +import { isNil } from "lodash-es"; +import { useInheritorsData } from "./context"; +import useInheritedAccountsColumns from "./hooks/useInheritedAccountsColumns"; + +export default function InheritedAccountsSection() { + const { inheritedAccounts, inheritedAccountsLoading } = useInheritorsData(); + const { desktopColumns, mobileColumns } = useInheritedAccountsColumns(); + const [navCollapsed] = useNavCollapsed(); + const [dataList, setDataList] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(true); + + const { page, component: pageComponent } = usePaginationComponent( + totalCount, + defaultPageSize, + ); + + const total = inheritedAccounts?.length || 0; + + useEffect(() => { + setLoading(true); + }, [page]); + + useEffect(() => { + if (inheritedAccountsLoading || isNil(inheritedAccounts)) { + return; + } + + setTotalCount(total); + const startIndex = (page - 1) * defaultPageSize; + const endIndex = startIndex + defaultPageSize; + setDataList(inheritedAccounts?.slice(startIndex, endIndex)); + setLoading(false); + }, [inheritedAccounts, inheritedAccountsLoading, page, total]); + + return ( +
+
+ + Inherited Accounts + + I'm the inheritor + + +
+
+ + + + + + {total > 0 && pageComponent} + +
+
+ ); +} diff --git a/packages/next-common/components/recovery/inheritors/inheritorFriendGroupsSection.jsx b/packages/next-common/components/recovery/inheritors/inheritorFriendGroupsSection.jsx new file mode 100644 index 0000000000..20c325de40 --- /dev/null +++ b/packages/next-common/components/recovery/inheritors/inheritorFriendGroupsSection.jsx @@ -0,0 +1,85 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { SecondaryCard } from "next-common/components/styled/containers/secondaryCard"; +import { MapDataList } from "next-common/components/dataList"; +import ScrollerX from "next-common/components/styled/containers/scrollerX"; +import { + desktopColumns, + mobileColumns, +} from "next-common/components/data/recovery/table/columns"; +import usePaginationComponent from "next-common/components/pagination/usePaginationComponent"; +import { defaultPageSize } from "next-common/utils/constants"; +import { useNavCollapsed } from "next-common/context/nav"; +import { cn } from "next-common/utils"; +import { isNil } from "lodash-es"; +import { useInheritorsData } from "./context"; + +export default function InheritorFriendGroupsSection() { + const { inheritorFriendGroups, inheritorFriendGroupsLoading } = + useInheritorsData(); + const [navCollapsed] = useNavCollapsed(); + const [dataList, setDataList] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(true); + + const { page, component: pageComponent } = usePaginationComponent( + totalCount, + defaultPageSize, + ); + + const total = inheritorFriendGroups?.length || 0; + + useEffect(() => { + setLoading(true); + }, [page]); + + useEffect(() => { + if (inheritorFriendGroupsLoading || isNil(inheritorFriendGroups)) { + return; + } + + setTotalCount(total); + const startIndex = (page - 1) * defaultPageSize; + const endIndex = startIndex + defaultPageSize; + setDataList(inheritorFriendGroups?.slice(startIndex, endIndex)); + setLoading(false); + }, [inheritorFriendGroups, inheritorFriendGroupsLoading, page, total]); + + return ( +
+
+ + Friend Groups + + I'm the inheritor + + +
+
+ + + + + + {total > 0 && pageComponent} + +
+
+ ); +} diff --git a/packages/next-common/components/recovery/myRecovery/addFriendGroupDialog.jsx b/packages/next-common/components/recovery/myRecovery/addFriendGroupDialog.jsx new file mode 100644 index 0000000000..1284793727 --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/addFriendGroupDialog.jsx @@ -0,0 +1,191 @@ +"use client"; + +import { useCallback, useState } from "react"; +import SignerPopupWrapper from "next-common/components/popupWithSigner/signerPopupWrapper"; +import SignerWithBalance from "next-common/components/signerPopup/signerWithBalance"; +import { + useExtensionAccounts, + usePopupParams, + useSignerAccount, +} from "next-common/components/popupWithSigner/context"; +import Popup from "next-common/components/popup/wrapper/Popup"; +import TxSubmissionButton from "next-common/components/common/tx/txSubmissionButton"; +import AdvanceSettings from "next-common/components/summary/newProposalQuickStart/common/advanceSettings"; +import EstimatedGas from "next-common/components/estimatedGas"; +import { useContextApi } from "next-common/context/api"; +import { isSameAddress } from "next-common/utils"; +import { sortAddresses } from "@polkadot/util-crypto"; +import { + PriorityField, + FriendsField, + ThresholdField, + InheritorField, + DelayField, +} from "./friendGroupFields"; + +function AddFriendGroupForm({ onInBlock = () => {} }) { + const { onClose } = usePopupParams(); + const extensionAccounts = useExtensionAccounts(); + const api = useContextApi(); + const signerAccount = useSignerAccount(); + const address = signerAccount?.realAddress; + const accounts = (extensionAccounts || []).map((acc) => ({ + address: acc.address, + name: acc.meta?.name, + disabled: acc?.disabled ?? false, + })); + + const [priority, setPriority] = useState("0"); + const [friends, setFriends] = useState([""]); + const [threshold, setThreshold] = useState("1"); + const [inheritor, setInheritor] = useState(""); + const [inheritorDelay, setInheritorDelay] = useState(""); + const [cancelDelay, setCancelDelay] = useState(""); + + const addFriend = () => setFriends([...friends, ""]); + const removeFriend = (idx) => { + if (friends.length <= 1) return; + const next = friends.filter((_, i) => i !== idx); + setFriends(next); + if (parseInt(threshold) > next.length) { + setThreshold(String(next.length)); + } + }; + const updateFriend = (idx, addr) => { + const next = [...friends]; + next[idx] = addr; + setFriends(next); + }; + + const validFriendsCount = new Set(friends.filter(Boolean)).size; + + const getTxFunc = useCallback(async () => { + const validFriends = friends.filter(Boolean); + if (validFriends.length === 0) { + throw new Error("Please add at least one friend address"); + } + + if (validFriends.some((friend) => isSameAddress(friend, address))) { + throw new Error("Cannot add yourself as a friend"); + } + + const thresholdNum = parseInt(threshold); + if (!thresholdNum || thresholdNum < 1) { + throw new Error("Please enter a valid threshold"); + } + const uniqueFriendsCount = new Set(validFriends).size; + if (thresholdNum > uniqueFriendsCount) { + throw new Error("Threshold cannot exceed the number of friends"); + } + + if (!inheritor) { + throw new Error("Please enter the inheritor address"); + } + + const priorityNum = parseInt(priority); + if (isNaN(priorityNum)) { + throw new Error("Please select a valid priority"); + } + + const inheritorDelayNum = parseInt(inheritorDelay); + if (!inheritorDelay || isNaN(inheritorDelayNum) || inheritorDelayNum < 0) { + throw new Error("Please enter a valid inheritor delay"); + } + + const cancelDelayNum = parseInt(cancelDelay); + if (!cancelDelay || isNaN(cancelDelayNum) || cancelDelayNum < 0) { + throw new Error("Please enter a valid cancel delay"); + } + + const sortedFriends = sortAddresses(validFriends).filter( + (addr, i, arr) => i === 0 || addr !== arr[i - 1], + ); + + const raw = await api.query.recovery.friendGroups(address); + const json = raw.toJSON(); + const currentGroups = Array.isArray(json?.[0]) ? json[0] : []; + + const newGroup = { + friends: sortedFriends, + friendsNeeded: thresholdNum, + inheritor, + inheritancePriority: priorityNum, + inheritanceDelay: inheritorDelayNum, + cancelDelay: cancelDelayNum, + }; + + return api.tx.recovery.setFriendGroups([...currentGroups, newGroup]); + }, [ + api, + address, + friends, + threshold, + priority, + inheritor, + inheritorDelay, + cancelDelay, + ]); + + return ( + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default function AddFriendGroupDialog({ + onClose, + onInBlock = () => {}, +}) { + return ( + + + + ); +} diff --git a/packages/next-common/components/recovery/myRecovery/attemptsTable.jsx b/packages/next-common/components/recovery/myRecovery/attemptsTable.jsx new file mode 100644 index 0000000000..d53ae67170 --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/attemptsTable.jsx @@ -0,0 +1,89 @@ +"use client"; + +import { isNil } from "lodash-es"; +import usePaginationComponent from "next-common/components/pagination/usePaginationComponent"; +import { defaultPageSize } from "next-common/utils/constants"; +import { useEffect, useState } from "react"; +import { SecondaryCard } from "next-common/components/styled/containers/secondaryCard"; +import { MapDataList } from "next-common/components/dataList"; +import ScrollerX from "next-common/components/styled/containers/scrollerX"; +import { useNavCollapsed } from "next-common/context/nav"; +import { cn } from "next-common/utils"; +import useRecoveryAttemptsTableColumns from "./hooks/useRecoveryAttemptsTableColumns"; + +function enhanceAttemptWithFriendGroup(attempt, friendGroups = []) { + const fgList = friendGroups?.find((fg) => fg.account === attempt.lostAccount); + const fgGroup = fgList?.friendGroups?.[attempt.friendGroupIndex]; + return { ...attempt, fgGroup }; +} + +export default function MyRecoveryAttemptsTable({ + data, + loading: isLoading, + friendGroups, + onSlash = () => {}, + onCancel = () => {}, +}) { + const [navCollapsed] = useNavCollapsed(); + const [dataList, setDataList] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(true); + + const { desktopColumns, mobileColumns } = useRecoveryAttemptsTableColumns( + onSlash, + onCancel, + ); + + const { page, component: pageComponent } = usePaginationComponent( + totalCount, + defaultPageSize, + ); + + const total = data?.length || 0; + + useEffect(() => { + setLoading(true); + }, [page]); + + useEffect(() => { + if (isLoading || isNil(data)) { + return; + } + + const enhanced = (data || []).map((attempt) => + enhanceAttemptWithFriendGroup(attempt, friendGroups), + ); + + setTotalCount(total); + const startIndex = (page - 1) * defaultPageSize; + const endIndex = startIndex + defaultPageSize; + setDataList(enhanced?.slice(startIndex, endIndex)); + setLoading(false); + }, [data, isLoading, page, total, friendGroups]); + + return ( + + + + + + + {total > 0 && pageComponent} + + ); +} diff --git a/packages/next-common/components/recovery/myRecovery/blockNumberWithTooltip.jsx b/packages/next-common/components/recovery/myRecovery/blockNumberWithTooltip.jsx new file mode 100644 index 0000000000..6627fceca2 --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/blockNumberWithTooltip.jsx @@ -0,0 +1,39 @@ +"use client"; + +import Tooltip from "next-common/components/tooltip"; +import { useRelayChainApi } from "next-common/context/relayChain"; +import useBlockTimestamp from "next-common/hooks/common/useBlockTimestamp"; +import FieldLoading from "next-common/components/icons/fieldLoading"; +import { formatTimeAgo } from "next-common/utils/viewfuncs/formatTimeAgo"; +import formatTime from "next-common/utils/viewfuncs/formatDate"; +import useNow from "next-common/hooks/useNow"; +import { isNil } from "lodash-es"; + +export default function BlockNumberWithTooltip({ height }) { + const api = useRelayChainApi(); + const { timestamp, isLoading } = useBlockTimestamp(height, api); + const now = useNow(30 * 1000); + + if (isNil(height)) { + return #0; + } + + const tooltipContent = isLoading ? ( + + ) : timestamp ? ( +
+
{formatTime(timestamp)}
+
+ {formatTimeAgo(timestamp, { referenceTime: now })} +
+
+ ) : null; + + return ( + + + #{height?.toLocaleString() || 0} + + + ); +} diff --git a/packages/next-common/components/recovery/myRecovery/cancelButton.jsx b/packages/next-common/components/recovery/myRecovery/cancelButton.jsx new file mode 100644 index 0000000000..a50fea9403 --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/cancelButton.jsx @@ -0,0 +1,35 @@ +"use client"; + +import { useState } from "react"; +import Tooltip from "next-common/components/tooltip"; +import CancelAttemptDialog from "next-common/components/recovery/helpOthers/cancelAttemptDialog"; + +export default function CancelButton({ + lostAccount, + friendGroupIndex, + onCancel, +}) { + const [showDialog, setShowDialog] = useState(false); + + return ( + <> + {showDialog && ( + setShowDialog(false)} + lostAccount={lostAccount} + friendGroupIndex={friendGroupIndex} + onInBlock={onCancel} + /> + )} + + + + + ); +} diff --git a/packages/next-common/components/recovery/myRecovery/context.js b/packages/next-common/components/recovery/myRecovery/context.js new file mode 100644 index 0000000000..255a4d9513 --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/context.js @@ -0,0 +1,75 @@ +"use client"; + +import { createContext, useContext, useMemo } from "react"; +import useMyFriendGroups from "./hooks/useMyFriendGroups"; +import useMyInheritor from "./hooks/useMyInheritor"; +import useMyRecoveryAttempts from "./hooks/useMyRecoveryAttempts"; + +const RecoveryDataContext = createContext(null); + +export function RecoveryDataProvider({ address, children }) { + const { + data: friendGroups, + loading: friendGroupsLoading, + fetch: fetchFriendGroups, + } = useMyFriendGroups(address); + + const { + data: inheritor, + loading: inheritorLoading, + fetch: fetchInheritor, + } = useMyInheritor(address); + + const { + data: attempts, + loading: attemptsLoading, + fetch: fetchAttempts, + } = useMyRecoveryAttempts(address); + + const loading = friendGroupsLoading || inheritorLoading || attemptsLoading; + + const value = useMemo( + () => ({ + friendGroups, + friendGroupsLoading, + fetchFriendGroups, + inheritor, + inheritorLoading, + fetchInheritor, + attempts, + attemptsLoading, + fetchAttempts, + loading, + address, + }), + [ + friendGroups, + friendGroupsLoading, + fetchFriendGroups, + inheritor, + inheritorLoading, + fetchInheritor, + attempts, + attemptsLoading, + fetchAttempts, + loading, + address, + ], + ); + + return ( + + {children} + + ); +} + +export function useRecoveryData() { + const context = useContext(RecoveryDataContext); + if (!context) { + throw new Error( + "useRecoveryData must be used within a RecoveryDataProvider", + ); + } + return context; +} diff --git a/packages/next-common/components/recovery/myRecovery/editFriendGroupDialog.jsx b/packages/next-common/components/recovery/myRecovery/editFriendGroupDialog.jsx new file mode 100644 index 0000000000..e3e0ffd0b3 --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/editFriendGroupDialog.jsx @@ -0,0 +1,206 @@ +"use client"; + +import { useCallback, useState } from "react"; +import SignerPopupWrapper from "next-common/components/popupWithSigner/signerPopupWrapper"; +import SignerWithBalance from "next-common/components/signerPopup/signerWithBalance"; +import { + useExtensionAccounts, + usePopupParams, + useSignerAccount, +} from "next-common/components/popupWithSigner/context"; +import Popup from "next-common/components/popup/wrapper/Popup"; +import TxSubmissionButton from "next-common/components/common/tx/txSubmissionButton"; +import AdvanceSettings from "next-common/components/summary/newProposalQuickStart/common/advanceSettings"; +import EstimatedGas from "next-common/components/estimatedGas"; +import { useContextApi } from "next-common/context/api"; +import { isSameAddress } from "next-common/utils"; +import { sortAddresses } from "@polkadot/util-crypto"; +import { + PriorityField, + FriendsField, + ThresholdField, + InheritorField, + DelayField, +} from "./friendGroupFields"; + +function EditFriendGroupForm({ onInBlock = () => {} }) { + const { onClose, group } = usePopupParams(); + const extensionAccounts = useExtensionAccounts(); + const api = useContextApi(); + const signerAccount = useSignerAccount(); + const address = signerAccount?.realAddress; + const accounts = (extensionAccounts || []).map((acc) => ({ + address: acc.address, + name: acc.meta?.name, + disabled: acc?.disabled ?? false, + })); + + const [priority, setPriority] = useState( + String(group.inheritancePriority ?? 0), + ); + const [friends, setFriends] = useState( + group.friends?.length ? group.friends : [""], + ); + const [threshold, setThreshold] = useState(String(group.friendsNeeded ?? 1)); + const [inheritor, setInheritor] = useState(group.inheritor || ""); + const [inheritorDelay, setInheritorDelay] = useState( + String(group.inheritanceDelay ?? ""), + ); + const [cancelDelay, setCancelDelay] = useState( + String(group.cancelDelay ?? ""), + ); + + const addFriend = () => setFriends([...friends, ""]); + const removeFriend = (idx) => { + if (friends.length <= 1) return; + const next = friends.filter((_, i) => i !== idx); + setFriends(next); + if (parseInt(threshold) > next.length) { + setThreshold(String(next.length)); + } + }; + const updateFriend = (idx, addr) => { + const next = [...friends]; + next[idx] = addr; + setFriends(next); + }; + + const validFriendsCount = new Set(friends.filter(Boolean)).size; + + const getTxFunc = useCallback(async () => { + const validFriends = friends.filter(Boolean); + if (validFriends.length === 0) { + throw new Error("Please add at least one friend address"); + } + + if (validFriends.some((friend) => isSameAddress(friend, address))) { + throw new Error("Cannot add yourself as a friend"); + } + + const thresholdNum = parseInt(threshold); + if (!thresholdNum || thresholdNum < 1) { + throw new Error("Please enter a valid threshold"); + } + const uniqueFriendsCount = new Set(validFriends).size; + if (thresholdNum > uniqueFriendsCount) { + throw new Error("Threshold cannot exceed the number of friends"); + } + + if (!inheritor) { + throw new Error("Please enter the inheritor address"); + } + + const priorityNum = parseInt(priority); + if (isNaN(priorityNum)) { + throw new Error("Please select a valid priority"); + } + + const inheritorDelayNum = parseInt(inheritorDelay); + if (!inheritorDelay || isNaN(inheritorDelayNum) || inheritorDelayNum < 0) { + throw new Error("Please enter a valid inheritor delay"); + } + + const cancelDelayNum = parseInt(cancelDelay); + if (!cancelDelay || isNaN(cancelDelayNum) || cancelDelayNum < 0) { + throw new Error("Please enter a valid cancel delay"); + } + + const sortedFriends = sortAddresses(validFriends).filter( + (addr, i, arr) => i === 0 || addr !== arr[i - 1], + ); + + const raw = await api.query.recovery.friendGroups(address); + const json = raw.toJSON(); + const currentGroups = Array.isArray(json?.[0]) ? json[0] : []; + + const updatedGroups = currentGroups.map((g, idx) => { + if (idx === group.index) { + return { + friends: sortedFriends, + friendsNeeded: thresholdNum, + inheritor, + inheritancePriority: priorityNum, + inheritanceDelay: inheritorDelayNum, + cancelDelay: cancelDelayNum, + }; + } + return g; + }); + + return api.tx.recovery.setFriendGroups(updatedGroups); + }, [ + api, + address, + friends, + threshold, + priority, + inheritor, + inheritorDelay, + cancelDelay, + group.index, + ]); + + return ( + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default function EditFriendGroupDialog({ + onClose, + group, + onInBlock = () => {}, +}) { + return ( + + + + ); +} diff --git a/packages/next-common/components/recovery/myRecovery/friendGroupFields.jsx b/packages/next-common/components/recovery/myRecovery/friendGroupFields.jsx new file mode 100644 index 0000000000..77dc998652 --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/friendGroupFields.jsx @@ -0,0 +1,128 @@ +import PopupLabel from "next-common/components/popup/label"; +import Select from "next-common/components/select"; +import NumberInput from "next-common/lib/input/number"; +import AddressCombo from "next-common/components/addressCombo"; +import { SystemClose } from "@osn/icons/subsquare"; + +const PRIORITY_OPTIONS = Array.from({ length: 10 }, (_, i) => ({ + label: String(i), + value: String(i), +})); + +export function PriorityField({ value, onChange }) { + return ( +
+ + onChange(value)} + small + /> +
+ ); +} + +export function InheritorField({ accounts, value, onChange }) { + return ( +
+ + onChange(addr || "")} + placeholder="Enter inheritor address" + size="small" + canEdit + /> +
+ ); +} + +export function DelayField({ label, tooltip, value, onChange }) { + return ( +
+ + +
+ ); +} diff --git a/packages/next-common/components/recovery/myRecovery/friendGroupsSection.jsx b/packages/next-common/components/recovery/myRecovery/friendGroupsSection.jsx new file mode 100644 index 0000000000..b691eecd9e --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/friendGroupsSection.jsx @@ -0,0 +1,209 @@ +"use client"; + +import { useState } from "react"; +import { SecondaryCard } from "next-common/components/styled/containers/secondaryCard"; +import Tooltip from "next-common/components/tooltip"; +import Loading from "next-common/components/loading"; +import AddressUser from "next-common/components/user/addressUser"; +import DelayBlock from "next-common/components/recovery/delayBlock"; +import { useRecoveryData } from "./context"; +import AddFriendGroupDialog from "./addFriendGroupDialog"; +import EditFriendGroupDialog from "./editFriendGroupDialog"; +import RemoveFriendGroupDialog from "./removeFriendGroupDialog"; + +function Field({ label, value }) { + return ( +
+ {label} + {value} +
+ ); +} + +function FriendGroupCard({ group, onEdit, onRemove }) { + const [showAllFriends, setShowAllFriends] = useState(false); + const displayedFriends = showAllFriends + ? group.friends || [] + : (group.friends || []).slice(0, 5); + const hasMore = (group.friends || []).length > 5; + + return ( + + {/* Card Header */} +
+ + Group #{group.index} + +
+ + + + + + +
+
+ + {/* Card Content */} +
+
+ + + + + ) : ( + None + ) + } + /> + } + /> + } + /> +
+ + + {displayedFriends.map((friend, idx) => ( +
+ +
+ ))} + {hasMore && !showAllFriends && ( + + )} + {showAllFriends && ( + + )} +
+ } + /> + +
+ ); +} + +export default function FriendGroupsSection() { + const { friendGroups, friendGroupsLoading, fetchFriendGroups, address } = + useRecoveryData(); + const [showAddDialog, setShowAddDialog] = useState(false); + const [showEditDialog, setShowEditDialog] = useState(false); + const [editingGroup, setEditingGroup] = useState(null); + const [showRemoveDialog, setShowRemoveDialog] = useState(false); + const [removingIndex, setRemovingIndex] = useState(null); + + return ( +
+ {showAddDialog && ( + setShowAddDialog(false)} + onInBlock={fetchFriendGroups} + /> + )} + {showEditDialog && editingGroup && ( + { + setShowEditDialog(false); + setEditingGroup(null); + }} + group={editingGroup} + onInBlock={fetchFriendGroups} + /> + )} + {showRemoveDialog && ( + { + setShowRemoveDialog(false); + setRemovingIndex(null); + }} + index={removingIndex} + address={address} + onInBlock={fetchFriendGroups} + /> + )} +
+ + My Friend Groups + + + + +
+ {friendGroupsLoading ? ( +
+ +
+ ) : friendGroups.length === 0 ? ( + + + No friend groups found + + + ) : ( +
+ {friendGroups.map((group) => ( + { + setEditingGroup(g); + setShowEditDialog(true); + }} + onRemove={(index) => { + setRemovingIndex(index); + setShowRemoveDialog(true); + }} + /> + ))} +
+ )} +
+ ); +} diff --git a/packages/next-common/components/recovery/myRecovery/hooks/useMyFriendGroups.js b/packages/next-common/components/recovery/myRecovery/hooks/useMyFriendGroups.js new file mode 100644 index 0000000000..422429d3dd --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/hooks/useMyFriendGroups.js @@ -0,0 +1,14 @@ +import { useMemo } from "react"; +import useQueryAllFriendGroups from "next-common/components/data/recovery/hooks/useQueryAllFriendGroups"; + +export default function useMyFriendGroups(address) { + const { data: allData, loading, fetch } = useQueryAllFriendGroups(); + + const data = useMemo(() => { + if (!address) return []; + const entry = allData.find((e) => e.account === address); + return entry?.friendGroups || []; + }, [allData, address]); + + return { data, loading, fetch }; +} diff --git a/packages/next-common/components/recovery/myRecovery/hooks/useMyInheritor.js b/packages/next-common/components/recovery/myRecovery/hooks/useMyInheritor.js new file mode 100644 index 0000000000..8b403f5373 --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/hooks/useMyInheritor.js @@ -0,0 +1,15 @@ +import { useMemo } from "react"; +import useQueryAllRecoveryInheritors from "next-common/components/data/recovery/hooks/useQueryAllRecoveryInheritors"; + +export default function useMyInheritor(address) { + const { data: allData, loading, fetch } = useQueryAllRecoveryInheritors(); + + const data = useMemo(() => { + if (!address) return []; + return allData.filter( + (item) => item.account?.toLowerCase() === address?.toLowerCase(), + ); + }, [allData, address]); + + return { data, loading, fetch }; +} diff --git a/packages/next-common/components/recovery/myRecovery/hooks/useMyRecoveryAttempts.js b/packages/next-common/components/recovery/myRecovery/hooks/useMyRecoveryAttempts.js new file mode 100644 index 0000000000..340ff7b3f2 --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/hooks/useMyRecoveryAttempts.js @@ -0,0 +1,13 @@ +import { useMemo } from "react"; +import useQueryAllRecoveryAttempts from "next-common/components/data/recovery/hooks/useQueryAllRecoveryAttempts"; + +export default function useMyRecoveryAttempts(address) { + const { data: attempts, loading, fetch } = useQueryAllRecoveryAttempts(); + + const data = useMemo(() => { + if (!address) return []; + return attempts.filter((a) => a.lostAccount === address); + }, [attempts, address]); + + return { data, loading, fetch }; +} diff --git a/packages/next-common/components/recovery/myRecovery/hooks/useMyRecoveryInheritorColumns.jsx b/packages/next-common/components/recovery/myRecovery/hooks/useMyRecoveryInheritorColumns.jsx new file mode 100644 index 0000000000..144d556f06 --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/hooks/useMyRecoveryInheritorColumns.jsx @@ -0,0 +1,69 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Tooltip from "next-common/components/tooltip"; +import { inheritorColumns } from "next-common/components/recovery/common/columns"; +import RevokeInheritorDialog from "../revokeInheritorDialog"; + +function RevokeButton({ onRevoke, inheritor }) { + const [showDialog, setShowDialog] = useState(false); + + return ( + <> + {showDialog && ( + setShowDialog(false)} + onInBlock={onRevoke} + /> + )} + + + + + ); +} + +function MyInheritorActions({ onRevoke, inheritor }) { + return ; +} + +export default function useMyRecoveryInheritorColumns(onRevoke) { + return useMemo(() => { + const desktopColumns = [ + inheritorColumns.inheritor("min-w-[200px] text-left"), + inheritorColumns.priority("w-[120px] text-left"), + inheritorColumns.depositor("min-w-[200px] text-left"), + inheritorColumns.deposit("w-[180px] text-left"), + { + name: "Action", + className: "w-[100px] text-right", + render: (item) => ( + + ), + }, + ]; + + const mobileColumns = [ + inheritorColumns.inheritor("text-left"), + inheritorColumns.priority("text-right"), + inheritorColumns.depositor("text-left"), + inheritorColumns.deposit("text-right"), + { + name: "Action", + className: "text-left", + render: (item) => ( + + ), + }, + ]; + + return { desktopColumns, mobileColumns }; + }, [onRevoke]); +} diff --git a/packages/next-common/components/recovery/myRecovery/hooks/useRecoveryAttemptsTableColumns.jsx b/packages/next-common/components/recovery/myRecovery/hooks/useRecoveryAttemptsTableColumns.jsx new file mode 100644 index 0000000000..f13de773eb --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/hooks/useRecoveryAttemptsTableColumns.jsx @@ -0,0 +1,65 @@ +"use client"; + +import { useMemo } from "react"; +import { attemptColumns } from "next-common/components/recovery/common/columns"; +import SlashButton from "../slashButton"; +import CancelButton from "../cancelButton"; + +function AttemptActions({ lostAccount, friendGroupIndex, onSlash, onCancel }) { + return ( +
+ + +
+ ); +} + +export default function useRecoveryAttemptsTableColumns(onSlash, onCancel) { + return useMemo(() => { + const desktopColumns = [ + attemptColumns.groupIndex("w-[120px] text-left"), + attemptColumns.initiator("min-w-[200px] text-left"), + attemptColumns.initBlock("w-[180px] text-left"), + attemptColumns.lastApprovalBlock("w-[200px] text-left"), + attemptColumns.thresholdApprovals("w-[160px] text-left"), + { + name: "Action", + className: "w-[160px] text-right", + render: (item) => ( + + ), + }, + ]; + + const mobileColumns = [ + attemptColumns.groupIndex("text-right"), + attemptColumns.initiator("text-left"), + attemptColumns.initBlock("text-right"), + attemptColumns.lastApprovalBlock("text-right"), + attemptColumns.thresholdApprovals("text-right"), + { + name: "Action", + className: "text-right", + render: (item) => ( + + ), + }, + ]; + + return { desktopColumns, mobileColumns }; + }, [onSlash, onCancel]); +} diff --git a/packages/next-common/components/recovery/myRecovery/index.jsx b/packages/next-common/components/recovery/myRecovery/index.jsx new file mode 100644 index 0000000000..40bd57ff49 --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/index.jsx @@ -0,0 +1,85 @@ +"use client"; + +import useRealAddress from "next-common/utils/hooks/useRealAddress"; +import FriendGroupsSection from "./friendGroupsSection"; +import InheritorSection from "./inheritorSection"; +import RecoveryAttemptsSection from "./recoveryAttemptsSection"; +import RecoverySubTabs from "next-common/components/recovery/subTabs"; +import { RelayChainApiProvider } from "next-common/context/relayChain"; +import { RecoveryDataProvider, useRecoveryData } from "./context"; +import Loading from "next-common/components/loading"; + +function MyRecoverySections() { + const { loading, attempts, inheritor, friendGroups } = useRecoveryData(); + + if (loading) { + return ( +
+ +
+ ); + } + + // 1) Sections with data always come before sections without data + // 2) With data priority: attempts(1), inheritor(2), friend groups(3) + // 3) Without data priority: friend groups(1), attempts(2), inheritor(3) + const hasData = (arr) => arr && arr.length > 0; + + const sectionConfigs = [ + { + key: "recoveryAttempts", + hasData: hasData(attempts), + Component: RecoveryAttemptsSection, + priorityWithData: 1, + priorityWithoutData: 2, + }, + { + key: "inheritors", + hasData: hasData(inheritor), + Component: InheritorSection, + priorityWithData: 2, + priorityWithoutData: 3, + }, + { + key: "friendGroups", + hasData: hasData(friendGroups), + Component: FriendGroupsSection, + priorityWithData: 3, + priorityWithoutData: 1, + }, + ]; + + const sorted = [...sectionConfigs].sort((a, b) => { + // Data-bearing sections first + if (a.hasData !== b.hasData) { + return a.hasData ? -1 : 1; + } + // Same group: sort by the corresponding priority + const pa = a.hasData ? a.priorityWithData : a.priorityWithoutData; + const pb = b.hasData ? b.priorityWithData : b.priorityWithoutData; + return pa - pb; + }); + + return ( + <> + {sorted.map(({ key, Component }) => ( + + ))} + + ); +} + +export default function MyRecoveryContent() { + const address = useRealAddress(); + + return ( + + +
+ + +
+
+
+ ); +} diff --git a/packages/next-common/components/recovery/myRecovery/inheritorSection.jsx b/packages/next-common/components/recovery/myRecovery/inheritorSection.jsx new file mode 100644 index 0000000000..df405678b0 --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/inheritorSection.jsx @@ -0,0 +1,80 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { SecondaryCard } from "next-common/components/styled/containers/secondaryCard"; +import { MapDataList } from "next-common/components/dataList"; +import ScrollerX from "next-common/components/styled/containers/scrollerX"; +import usePaginationComponent from "next-common/components/pagination/usePaginationComponent"; +import { defaultPageSize } from "next-common/utils/constants"; +import { useNavCollapsed } from "next-common/context/nav"; +import { cn } from "next-common/utils"; +import { isNil } from "lodash-es"; +import { useRecoveryData } from "./context"; +import useMyRecoveryInheritorColumns from "./hooks/useMyRecoveryInheritorColumns"; + +export default function InheritorSection() { + const { inheritor, inheritorLoading, fetchInheritor } = useRecoveryData(); + const { desktopColumns, mobileColumns } = + useMyRecoveryInheritorColumns(fetchInheritor); + const [navCollapsed] = useNavCollapsed(); + const [dataList, setDataList] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(true); + + const { page, component: pageComponent } = usePaginationComponent( + totalCount, + defaultPageSize, + ); + + const total = inheritor?.length || 0; + + useEffect(() => { + setLoading(true); + }, [page]); + + useEffect(() => { + if (inheritorLoading || isNil(inheritor)) { + return; + } + + setTotalCount(total); + const startIndex = (page - 1) * defaultPageSize; + const endIndex = startIndex + defaultPageSize; + setDataList(inheritor?.slice(startIndex, endIndex)); + setLoading(false); + }, [inheritor, inheritorLoading, page, total]); + + return ( +
+
+ + My Account Inheritor + +
+
+ + + + + + {total > 0 && pageComponent} + +
+
+ ); +} diff --git a/packages/next-common/components/recovery/myRecovery/recoveryAttemptsSection.jsx b/packages/next-common/components/recovery/myRecovery/recoveryAttemptsSection.jsx new file mode 100644 index 0000000000..43748b9555 --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/recoveryAttemptsSection.jsx @@ -0,0 +1,32 @@ +"use client"; + +import MyRecoveryAttemptsTable from "./attemptsTable"; +import { useRecoveryData } from "./context"; + +export default function RecoveryAttemptsSection() { + const { attempts, attemptsLoading, fetchAttempts, friendGroups, address } = + useRecoveryData(); + + const friendGroupsFormatted = address + ? [{ account: address, friendGroups: friendGroups }] + : []; + + return ( +
+
+ + Ongoing Recovery Attempts + +
+
+ +
+
+ ); +} diff --git a/packages/next-common/components/recovery/myRecovery/removeFriendGroupDialog.jsx b/packages/next-common/components/recovery/myRecovery/removeFriendGroupDialog.jsx new file mode 100644 index 0000000000..c843fb57b9 --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/removeFriendGroupDialog.jsx @@ -0,0 +1,33 @@ +"use client"; + +import React, { useCallback } from "react"; +import SimpleTxPopup from "next-common/components/simpleTxPopup"; +import { useContextApi } from "next-common/context/api"; + +export default function RemoveFriendGroupDialog({ + onClose, + index, + address, + onInBlock = () => {}, +}) { + const api = useContextApi(); + + const getTxFunc = useCallback(async () => { + const raw = await api.query.recovery.friendGroups(address); + const json = raw.toJSON(); + const currentGroups = Array.isArray(json?.[0]) ? json[0] : []; + + const updatedGroups = currentGroups.filter((_, idx) => idx !== index); + + return api.tx.recovery.setFriendGroups(updatedGroups); + }, [api, address, index]); + + return ( + + ); +} diff --git a/packages/next-common/components/recovery/myRecovery/revokeInheritorDialog.jsx b/packages/next-common/components/recovery/myRecovery/revokeInheritorDialog.jsx new file mode 100644 index 0000000000..81fef776cf --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/revokeInheritorDialog.jsx @@ -0,0 +1,46 @@ +"use client"; + +import { useCallback } from "react"; +import AddressUser from "next-common/components/user/addressUser"; +import SimpleTxPopup from "next-common/components/simpleTxPopup"; +import { useContextApi } from "next-common/context/api"; + +function InfoRow({ label, children }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +export default function RevokeInheritorDialog({ + onClose, + inheritor, + onInBlock = () => {}, +}) { + const api = useContextApi(); + + const getTxFunc = useCallback(async () => { + return api.tx.recovery.revokeInheritor(); + }, [api]); + + return ( + +
+ + + + + + + {inheritor?.inheritancePriority} +
+
+ ); +} diff --git a/packages/next-common/components/recovery/myRecovery/slashAttemptDialog.jsx b/packages/next-common/components/recovery/myRecovery/slashAttemptDialog.jsx new file mode 100644 index 0000000000..29a7788cb9 --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/slashAttemptDialog.jsx @@ -0,0 +1,26 @@ +"use client"; + +import { useCallback } from "react"; +import SimpleTxPopup from "next-common/components/simpleTxPopup"; +import { useContextApi } from "next-common/context/api"; + +export default function SlashAttemptDialog({ + onClose, + friendGroupIndex, + onInBlock = () => {}, +}) { + const api = useContextApi(); + + const getTxFunc = useCallback(async () => { + return api.tx.recovery.slashAttempt(friendGroupIndex); + }, [api, friendGroupIndex]); + + return ( + + ); +} diff --git a/packages/next-common/components/recovery/myRecovery/slashButton.jsx b/packages/next-common/components/recovery/myRecovery/slashButton.jsx new file mode 100644 index 0000000000..5ebcff0ca9 --- /dev/null +++ b/packages/next-common/components/recovery/myRecovery/slashButton.jsx @@ -0,0 +1,30 @@ +"use client"; + +import { useState } from "react"; +import Tooltip from "next-common/components/tooltip"; +import SlashAttemptDialog from "./slashAttemptDialog"; + +export default function SlashButton({ friendGroupIndex, onSlash }) { + const [showDialog, setShowDialog] = useState(false); + + return ( + <> + {showDialog && ( + setShowDialog(false)} + friendGroupIndex={friendGroupIndex} + onInBlock={onSlash} + /> + )} + + + + + ); +} diff --git a/packages/next-common/components/recovery/subTabs.jsx b/packages/next-common/components/recovery/subTabs.jsx new file mode 100644 index 0000000000..6b10e2c507 --- /dev/null +++ b/packages/next-common/components/recovery/subTabs.jsx @@ -0,0 +1,42 @@ +"use client"; + +import Tabs from "next-common/components/tabs"; +import { Title } from "next-common/components/overview/account/styled"; + +function TabTitle({ active, children }) { + return ( + + {children} + + ); +} + +const SUB_TABS = [ + { + value: "my_recovery", + label: ({ active }) => My Recovery, + url: "/account/my-recovery", + }, + { + value: "help_recover", + label: ({ active }) => Help Others, + url: "/account/help-recover", + }, + { + value: "inherited", + label: ({ active }) => Inherited, + url: "/account/inherited", + }, +]; + +export default function RecoverySubTabs({ activeTab, className = "" }) { + return ( + + ); +} diff --git a/packages/next-common/hooks/common/useBlockTimestamp.js b/packages/next-common/hooks/common/useBlockTimestamp.js index e78828ab2e..41c683c45f 100644 --- a/packages/next-common/hooks/common/useBlockTimestamp.js +++ b/packages/next-common/hooks/common/useBlockTimestamp.js @@ -15,23 +15,33 @@ export default function useBlockTimestamp(height, specifiedApi = null) { const [isEstimated, setIsEstimated] = useState(false); const [isLoading, setIsLoading] = useState(false); + const isBlockLoaded = chainHeight >= height; + + useEffect(() => { + if (isBlockLoaded && api) { + return; + } + + const now = new Date().getTime(); + setTimestamp( + BigNumber(oneBlockTime) + .multipliedBy(height - chainHeight) + .plus(now) + .toNumber(), + ); + setIsEstimated(true); + }, [api, height, chainHeight, isBlockLoaded, oneBlockTime]); + useEffect(() => { - if (chainHeight >= height && api) { - setIsLoading(true); - getBlockTimeByHeight(api, height) - .then((v) => setTimestamp(v)) - .finally(() => setIsLoading(false)); - } else { - const now = new Date().getTime(); - setTimestamp( - BigNumber(oneBlockTime) - .multipliedBy(height - chainHeight) - .plus(now) - .toNumber(), - ); - setIsEstimated(true); + if (!isBlockLoaded || !api) { + return; } - }, [api, height, chainHeight, oneBlockTime]); + + setIsLoading(true); + getBlockTimeByHeight(api, height) + .then((v) => setTimestamp(v)) + .finally(() => setIsLoading(false)); + }, [api, height, isBlockLoaded]); return { timestamp, diff --git a/packages/next-common/hooks/useRelayChainBlockNumber.js b/packages/next-common/hooks/useRelayChainBlockNumber.js new file mode 100644 index 0000000000..fb5293898a --- /dev/null +++ b/packages/next-common/hooks/useRelayChainBlockNumber.js @@ -0,0 +1,39 @@ +import { useEffect, useState } from "react"; +import { useRelayChainApi } from "next-common/context/relayChain"; + +export default function useRelayChainBlockNumber() { + const api = useRelayChainApi(); + const [blockNumber, setBlockNumber] = useState(null); + + useEffect(() => { + if (!api) { + return; + } + + let unsubscribe; + let isMounted = true; + + api.rpc.chain + .subscribeNewHeads((header) => { + if (isMounted) { + setBlockNumber(header.number.toNumber()); + } + }) + .then((unsub) => { + if (isMounted) { + unsubscribe = unsub; + } else { + unsub(); + } + }); + + return () => { + isMounted = false; + if (unsubscribe) { + unsubscribe(); + } + }; + }, [api]); + + return blockNumber; +} diff --git a/packages/next-common/utils/constants.js b/packages/next-common/utils/constants.js index cb82197c4a..4fb9353fce 100644 --- a/packages/next-common/utils/constants.js +++ b/packages/next-common/utils/constants.js @@ -147,6 +147,9 @@ export const CACHE_KEY = { nominatorClaimRewardPrompt: "nominator-claim-reward-prompt", poolWithdrawUnbondedPrompt: "pool-withdraw-unbonded-prompt", poolClaimRewardPrompt: "pool-claim-reward-prompt", + + ongoingRecoveryAttemptsPrompt: "ongoing-recovery-attempts-prompt", + recoveryInheritorPrompt: "recovery-inheritor-prompt", }; export const ADDRESS_CACHE_KEYS = [ @@ -168,6 +171,9 @@ export const ADDRESS_CACHE_KEYS = [ CACHE_KEY.nominatorClaimRewardPrompt, CACHE_KEY.poolWithdrawUnbondedPrompt, CACHE_KEY.poolClaimRewardPrompt, + + CACHE_KEY.ongoingRecoveryAttemptsPrompt, + CACHE_KEY.recoveryInheritorPrompt, ]; export const CHAIN = process.env.NEXT_PUBLIC_CHAIN; diff --git a/packages/next-common/utils/consts/menu/common.js b/packages/next-common/utils/consts/menu/common.js index 2aa27606ae..ae79a40cf2 100644 --- a/packages/next-common/utils/consts/menu/common.js +++ b/packages/next-common/utils/consts/menu/common.js @@ -27,7 +27,10 @@ export const accountMenu = { "/account/been-delegated", "/account/delegations", "/account/deposits", + "/account/help-recover", + "/account/inherited", "/account/multisigs", + "/account/my-recovery", "/account/proxies", ], icon: , diff --git a/packages/next-common/utils/consts/menu/data.js b/packages/next-common/utils/consts/menu/data.js index 513038f54d..e050dc283c 100644 --- a/packages/next-common/utils/consts/menu/data.js +++ b/packages/next-common/utils/consts/menu/data.js @@ -1,10 +1,6 @@ import { MenuData } from "@osn/icons/subsquare"; -import getChainSettings from "../settings"; -import { CHAIN } from "next-common/utils/constants"; function getDataMenu() { - const { modules } = getChainSettings(CHAIN); - const children = [ { name: "Proxies", @@ -18,14 +14,6 @@ function getDataMenu() { }, ]; - if (modules?.recovery) { - children.push({ - name: "Recovery", - value: "recovery", - pathname: "/recovery", - }); - } - return { name: "Data", value: "data", diff --git a/packages/next/pages/account/help-recover.jsx b/packages/next/pages/account/help-recover.jsx new file mode 100644 index 0000000000..ae311dff61 --- /dev/null +++ b/packages/next/pages/account/help-recover.jsx @@ -0,0 +1,19 @@ +import HelpOthersContent from "next-common/components/recovery/helpOthers"; +import AccountLayout from "next-common/components/layout/AccountLayout"; +import AccountSubTabs from "next-common/components/overview/account/subTabs"; +import { withCommonProps } from "next-common/lib"; + +export default function AccountHelpRecoverPage() { + return ( + +
+ + +
+
+ ); +} + +export const getServerSideProps = withCommonProps(); diff --git a/packages/next/pages/account/inherited.jsx b/packages/next/pages/account/inherited.jsx new file mode 100644 index 0000000000..19e862e92b --- /dev/null +++ b/packages/next/pages/account/inherited.jsx @@ -0,0 +1,17 @@ +import InheritorsContent from "next-common/components/recovery/inheritors"; +import AccountLayout from "next-common/components/layout/AccountLayout"; +import AccountSubTabs from "next-common/components/overview/account/subTabs"; +import { withCommonProps } from "next-common/lib"; + +export default function AccountInheritedPage() { + return ( + +
+ + +
+
+ ); +} + +export const getServerSideProps = withCommonProps(); diff --git a/packages/next/pages/account/my-recovery.jsx b/packages/next/pages/account/my-recovery.jsx new file mode 100644 index 0000000000..9b6c951364 --- /dev/null +++ b/packages/next/pages/account/my-recovery.jsx @@ -0,0 +1,17 @@ +import MyRecoveryContent from "next-common/components/recovery/myRecovery"; +import AccountLayout from "next-common/components/layout/AccountLayout"; +import AccountSubTabs from "next-common/components/overview/account/subTabs"; +import { withCommonProps } from "next-common/lib"; + +export default function AccountMyRecoveryPage() { + return ( + +
+ + +
+
+ ); +} + +export const getServerSideProps = withCommonProps();