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 (
+
+
+
+ );
+}
+
+export function FriendsField({ friends, accounts, onAdd, onRemove, onUpdate }) {
+ return (
+
+
+
+ {friends.map((addr, idx) => (
+
+
+
onUpdate(idx, value || "")}
+ placeholder="Enter friend address"
+ size="small"
+ canEdit
+ />
+
+ {friends.length > 1 && (
+
+ )}
+
+ ))}
+
+
+ );
+}
+
+export function ThresholdField({ value, onChange, max }) {
+ const options = Array.from({ length: Math.max(1, max) }, (_, i) => ({
+ label: String(i + 1),
+ value: String(i + 1),
+ }));
+
+ return (
+
+
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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();