mirror of
https://github.com/comit-network/xmr-btc-swap.git
synced 2025-12-16 00:53:58 -05:00
prune(gui): dead code (#779)
This commit is contained in:
parent
801ce8fd9d
commit
c25d851187
21 changed files with 10 additions and 294 deletions
|
|
@ -40,11 +40,6 @@ export type PrimitiveDateTimeString = [
|
|||
number, // Offset Second
|
||||
];
|
||||
|
||||
export interface Feedback {
|
||||
id: string;
|
||||
created_at: PrimitiveDateTimeString;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: number;
|
||||
message_id: number;
|
||||
|
|
@ -62,11 +57,6 @@ export interface Message {
|
|||
attachments?: Attachment[];
|
||||
}
|
||||
|
||||
export interface MessageWithAttachments {
|
||||
message: Message;
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
// Define type for Attachment data in request body
|
||||
export interface AttachmentInput {
|
||||
key: string;
|
||||
|
|
|
|||
|
|
@ -157,25 +157,6 @@ export function isBobStateNameRunningSwap(
|
|||
].includes(state);
|
||||
}
|
||||
|
||||
export type BobStateNameCompletedSwap =
|
||||
| BobStateName.XmrRedeemed
|
||||
| BobStateName.BtcRefunded
|
||||
| BobStateName.BtcEarlyRefunded
|
||||
| BobStateName.BtcPunished
|
||||
| BobStateName.SafelyAborted;
|
||||
|
||||
export function isBobStateNameCompletedSwap(
|
||||
state: BobStateName,
|
||||
): state is BobStateNameCompletedSwap {
|
||||
return [
|
||||
BobStateName.XmrRedeemed,
|
||||
BobStateName.BtcRefunded,
|
||||
BobStateName.BtcEarlyRefunded,
|
||||
BobStateName.BtcPunished,
|
||||
BobStateName.SafelyAborted,
|
||||
].includes(state);
|
||||
}
|
||||
|
||||
export type BobStateNamePossiblyCancellableSwap =
|
||||
| BobStateName.BtcLocked
|
||||
| BobStateName.XmrLockProofReceived
|
||||
|
|
|
|||
|
|
@ -5,16 +5,7 @@
|
|||
// - and to submit feedback
|
||||
// - fetch currency rates from CoinGecko
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Attachment,
|
||||
AttachmentInput,
|
||||
ExtendedMakerStatus,
|
||||
Feedback,
|
||||
Message,
|
||||
MessageWithAttachments,
|
||||
PrimitiveDateTimeString,
|
||||
} from "models/apiModel";
|
||||
import { Alert, AttachmentInput, Message } from "models/apiModel";
|
||||
import { store } from "./store/storeRenderer";
|
||||
import {
|
||||
setBtcPrice,
|
||||
|
|
@ -28,11 +19,6 @@ import { setConversation } from "store/features/conversationsSlice";
|
|||
|
||||
const PUBLIC_REGISTRY_API_BASE_URL = "https://api.unstoppableswap.net";
|
||||
|
||||
async function fetchMakersViaHttp(): Promise<ExtendedMakerStatus[]> {
|
||||
const response = await fetch(`${PUBLIC_REGISTRY_API_BASE_URL}/api/list`);
|
||||
return (await response.json()) as ExtendedMakerStatus[];
|
||||
}
|
||||
|
||||
async function fetchAlertsViaHttp(): Promise<Alert[]> {
|
||||
const response = await fetch(`${PUBLIC_REGISTRY_API_BASE_URL}/api/alerts`);
|
||||
return (await response.json()) as Alert[];
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
import { BackgroundRefundState } from "models/tauriModel";
|
||||
import { useAppSelector } from "store/hooks";
|
||||
import { LoadingSpinnerAlert } from "./LoadingSpinnerAlert";
|
||||
import { AlertTitle } from "@mui/material";
|
||||
import TruncatedText from "../other/TruncatedText";
|
||||
import { useSnackbar } from "notistack";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function BackgroundRefundAlert() {
|
||||
const backgroundRefund = useAppSelector(
|
||||
(state) => state.rpc.state.backgroundRefund,
|
||||
);
|
||||
const notistack = useSnackbar();
|
||||
|
||||
useEffect(() => {
|
||||
// If we failed to refund, show a notification
|
||||
if (backgroundRefund?.state.type === "Failed") {
|
||||
notistack.enqueueSnackbar(
|
||||
<>
|
||||
Our attempt to refund {backgroundRefund.swapId} in the background
|
||||
failed.
|
||||
<br />
|
||||
Error: {backgroundRefund.state.content.error}
|
||||
</>,
|
||||
{ variant: "error", autoHideDuration: 60 * 1000 },
|
||||
);
|
||||
}
|
||||
|
||||
// If we successfully refunded, show a notification as well
|
||||
if (backgroundRefund?.state.type === "Completed") {
|
||||
notistack.enqueueSnackbar(
|
||||
`The swap ${backgroundRefund.swapId} has been refunded in the background.`,
|
||||
{ variant: "success", persist: true },
|
||||
);
|
||||
}
|
||||
}, [backgroundRefund]);
|
||||
|
||||
if (backgroundRefund?.state.type === "Started") {
|
||||
return (
|
||||
<LoadingSpinnerAlert>
|
||||
<AlertTitle>Refund in progress</AlertTitle>
|
||||
The swap <TruncatedText>{backgroundRefund.swapId}</TruncatedText> is
|
||||
being refunded in the background.
|
||||
</LoadingSpinnerAlert>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { Box, Tooltip } from "@mui/material";
|
||||
import { BackgroundProgressAlerts } from "../alert/DaemonStatusAlert";
|
||||
import UnfinishedSwapsAlert from "../alert/UnfinishedSwapsAlert";
|
||||
import BackgroundRefundAlert from "../alert/BackgroundRefundAlert";
|
||||
import ContactInfoBox from "../other/ContactInfoBox";
|
||||
|
||||
export default function NavigationFooter() {
|
||||
|
|
@ -15,7 +14,6 @@ export default function NavigationFooter() {
|
|||
}}
|
||||
>
|
||||
<UnfinishedSwapsAlert />
|
||||
<BackgroundRefundAlert />
|
||||
<BackgroundProgressAlerts />
|
||||
<ContactInfoBox />
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -409,4 +409,4 @@ function PeerDetailsDialog({
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -600,15 +600,6 @@ export async function sendMoneroTransaction(
|
|||
}
|
||||
}
|
||||
|
||||
export async function updateMoneroSyncProgress() {
|
||||
try {
|
||||
const response = await getMoneroSyncProgress();
|
||||
store.dispatch(setSyncProgress(response));
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch sync progress:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDataDir(): Promise<string> {
|
||||
const testnet = isTestnet();
|
||||
return await invoke<GetDataDirArgs, string>("get_data_dir", {
|
||||
|
|
@ -671,12 +662,6 @@ export async function saveLogFiles(
|
|||
await invokeUnsafe<void>("save_txt_files", { zipFileName, content });
|
||||
}
|
||||
|
||||
export async function saveFilesInDialog(files: Record<string, string>) {
|
||||
await invokeUnsafe<void>("save_txt_files", {
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
export async function dfxAuthenticate(): Promise<DfxAuthenticateResponse> {
|
||||
return await invokeNoArgs<DfxAuthenticateResponse>("dfx_authenticate");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { ExtendedMakerStatus } from "models/apiModel";
|
||||
import { splitPeerIdFromMultiAddress } from "utils/parseUtils";
|
||||
import { CliMatches, getMatches } from "@tauri-apps/plugin-cli";
|
||||
import { Network } from "./types";
|
||||
|
||||
|
|
@ -24,36 +22,3 @@ export function getNetwork(): Network {
|
|||
export function isTestnet() {
|
||||
return matches.args.testnet?.value === true;
|
||||
}
|
||||
|
||||
export const isDevelopment = true;
|
||||
|
||||
export function getStubTestnetMaker(): ExtendedMakerStatus | null {
|
||||
const stubMakerAddress = import.meta.env.VITE_TESTNET_STUB_PROVIDER_ADDRESS;
|
||||
|
||||
if (stubMakerAddress != null) {
|
||||
try {
|
||||
const [multiAddr, peerId] = splitPeerIdFromMultiAddress(stubMakerAddress);
|
||||
|
||||
return {
|
||||
multiAddr,
|
||||
testnet: true,
|
||||
peerId,
|
||||
maxSwapAmount: 0,
|
||||
minSwapAmount: 0,
|
||||
price: 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getNetworkName(): string {
|
||||
if (isTestnet()) {
|
||||
return "Testnet";
|
||||
} else {
|
||||
return "Mainnet";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,13 +20,10 @@ export const bitcoinWalletSlice = createSlice({
|
|||
setBitcoinBalance(state, action: PayloadAction<number>) {
|
||||
state.balance = action.payload;
|
||||
},
|
||||
resetBitcoinWalletState(state) {
|
||||
return initialState;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setBitcoinAddress, setBitcoinBalance, resetBitcoinWalletState } =
|
||||
export const { setBitcoinAddress, setBitcoinBalance } =
|
||||
bitcoinWalletSlice.actions;
|
||||
|
||||
export default bitcoinWalletSlice.reducer;
|
||||
|
|
|
|||
|
|
@ -28,14 +28,6 @@ const conversationsSlice = createSlice({
|
|||
slice.knownFeedbackIds.push(action.payload);
|
||||
}
|
||||
},
|
||||
// Removes a feedback id from the list of known ones
|
||||
// Also removes the conversation from the store
|
||||
removeFeedback(slice, action: PayloadAction<string>) {
|
||||
slice.knownFeedbackIds = slice.knownFeedbackIds.filter(
|
||||
(id) => id !== action.payload,
|
||||
);
|
||||
delete slice.conversations[action.payload];
|
||||
},
|
||||
// Sets the conversations for a given feedback id (Payload uses the correct Message type)
|
||||
setConversation(
|
||||
slice,
|
||||
|
|
@ -53,10 +45,6 @@ const conversationsSlice = createSlice({
|
|||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
addFeedbackId,
|
||||
removeFeedback,
|
||||
setConversation,
|
||||
markMessagesAsSeen,
|
||||
} = conversationsSlice.actions;
|
||||
export const { addFeedbackId, setConversation, markMessagesAsSeen } =
|
||||
conversationsSlice.actions;
|
||||
export default conversationsSlice.reducer;
|
||||
|
|
|
|||
|
|
@ -45,9 +45,6 @@ export const logsSlice = createSlice({
|
|||
slice.state.logs = slice.state.logs.slice(removeCount);
|
||||
}
|
||||
},
|
||||
clearLogs(slice) {
|
||||
slice.state.logs = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -82,6 +79,6 @@ export function hashLogs(logs: (CliLog | string)[]): HashedLog[] {
|
|||
return logs.map(createHashedLog);
|
||||
}
|
||||
|
||||
export const { receivedCliLog, clearLogs } = logsSlice.actions;
|
||||
export const { receivedCliLog } = logsSlice.actions;
|
||||
|
||||
export default logsSlice.reducer;
|
||||
|
|
|
|||
|
|
@ -29,14 +29,8 @@ const nodesSlice = createSlice({
|
|||
slice.nodes[action.payload.blockchain][action.payload.node] =
|
||||
action.payload.status;
|
||||
},
|
||||
resetStatuses(slice) {
|
||||
slice.nodes = {
|
||||
[Blockchain.Bitcoin]: {},
|
||||
[Blockchain.Monero]: {},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setStatus, resetStatuses } = nodesSlice.actions;
|
||||
export const { setStatus } = nodesSlice.actions;
|
||||
export default nodesSlice.reducer;
|
||||
|
|
|
|||
|
|
@ -19,13 +19,9 @@ export const poolSlice = createSlice({
|
|||
slice.status = action.payload;
|
||||
slice.isLoading = false;
|
||||
},
|
||||
poolStatusReset(slice) {
|
||||
slice.status = null;
|
||||
slice.isLoading = true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { poolStatusReceived, poolStatusReset } = poolSlice.actions;
|
||||
export const { poolStatusReceived } = poolSlice.actions;
|
||||
|
||||
export default poolSlice.reducer;
|
||||
|
|
|
|||
|
|
@ -28,15 +28,9 @@ const ratesSlice = createSlice({
|
|||
setXmrBtcRate: (state, action: PayloadAction<number>) => {
|
||||
state.xmrBtcRate = action.payload;
|
||||
},
|
||||
resetRates: (state) => {
|
||||
state.btcPrice = null;
|
||||
state.xmrPrice = null;
|
||||
state.xmrBtcRate = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setBtcPrice, setXmrPrice, setXmrBtcRate, resetRates } =
|
||||
ratesSlice.actions;
|
||||
export const { setBtcPrice, setXmrPrice, setXmrBtcRate } = ratesSlice.actions;
|
||||
|
||||
export default ratesSlice.reducer;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
import { ExtendedMakerStatus, MakerStatus } from "models/apiModel";
|
||||
import {
|
||||
GetSwapInfoResponse,
|
||||
ContextStatus,
|
||||
TauriTimelockChangeEvent,
|
||||
BackgroundRefundState,
|
||||
ApprovalRequest,
|
||||
TauriBackgroundProgressWrapper,
|
||||
TauriBackgroundProgress,
|
||||
|
|
@ -12,10 +10,8 @@ import {
|
|||
} from "models/tauriModel";
|
||||
import { MoneroRecoveryResponse } from "../../models/rpcModel";
|
||||
import { GetSwapInfoResponseExt } from "models/tauriModelExt";
|
||||
import logger from "utils/logger";
|
||||
|
||||
interface State {
|
||||
withdrawTxId: string | null;
|
||||
swapInfos: {
|
||||
[swapId: string]: GetSwapInfoResponseExt;
|
||||
};
|
||||
|
|
@ -26,10 +22,6 @@ interface State {
|
|||
swapId: string;
|
||||
keys: MoneroRecoveryResponse;
|
||||
} | null;
|
||||
backgroundRefund: {
|
||||
swapId: string;
|
||||
state: BackgroundRefundState;
|
||||
} | null;
|
||||
approvalRequests: {
|
||||
// Store the full event, keyed by request_id
|
||||
[requestId: string]: ApprovalRequest;
|
||||
|
|
@ -56,12 +48,10 @@ export interface RPCSlice {
|
|||
const initialState: RPCSlice = {
|
||||
status: null,
|
||||
state: {
|
||||
withdrawTxId: null,
|
||||
swapInfos: {},
|
||||
swapTimelocks: {},
|
||||
moneroRecovery: null,
|
||||
background: {},
|
||||
backgroundRefund: null,
|
||||
approvalRequests: {},
|
||||
},
|
||||
};
|
||||
|
|
@ -92,12 +82,6 @@ export const rpcSlice = createSlice({
|
|||
action.payload.timelock;
|
||||
}
|
||||
},
|
||||
rpcSetWithdrawTxId(slice, action: PayloadAction<string>) {
|
||||
slice.state.withdrawTxId = action.payload;
|
||||
},
|
||||
rpcResetWithdrawTxId(slice) {
|
||||
slice.state.withdrawTxId = null;
|
||||
},
|
||||
rpcSetSwapInfo(slice, action: PayloadAction<GetSwapInfoResponse>) {
|
||||
slice.state.swapInfos[action.payload.swap_id] =
|
||||
action.payload as GetSwapInfoResponseExt;
|
||||
|
|
@ -117,15 +101,6 @@ export const rpcSlice = createSlice({
|
|||
rpcResetMoneroRecoveryKeys(slice) {
|
||||
slice.state.moneroRecovery = null;
|
||||
},
|
||||
rpcSetBackgroundRefundState(
|
||||
slice,
|
||||
action: PayloadAction<{ swap_id: string; state: BackgroundRefundState }>,
|
||||
) {
|
||||
slice.state.backgroundRefund = {
|
||||
swapId: action.payload.swap_id,
|
||||
state: action.payload.state,
|
||||
};
|
||||
},
|
||||
approvalEventReceived(slice, action: PayloadAction<ApprovalRequest>) {
|
||||
const event = action.payload;
|
||||
const requestId = event.request_id;
|
||||
|
|
@ -144,40 +119,19 @@ export const rpcSlice = createSlice({
|
|||
) {
|
||||
slice.state.background[action.payload.id] = action.payload.event;
|
||||
},
|
||||
backgroundProgressEventRemoved(slice, action: PayloadAction<string>) {
|
||||
delete slice.state.background[action.payload];
|
||||
},
|
||||
rpcSetBackgroundItems(
|
||||
slice,
|
||||
action: PayloadAction<{ [key: string]: TauriBackgroundProgress }>,
|
||||
) {
|
||||
slice.state.background = action.payload;
|
||||
},
|
||||
rpcSetApprovalItems(
|
||||
slice,
|
||||
action: PayloadAction<{ [requestId: string]: ApprovalRequest }>,
|
||||
) {
|
||||
slice.state.approvalRequests = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
contextStatusEventReceived,
|
||||
contextInitializationFailed,
|
||||
rpcSetWithdrawTxId,
|
||||
rpcResetWithdrawTxId,
|
||||
rpcSetSwapInfo,
|
||||
rpcSetMoneroRecoveryKeys,
|
||||
rpcResetMoneroRecoveryKeys,
|
||||
rpcSetBackgroundRefundState,
|
||||
timelockChangeEventReceived,
|
||||
approvalEventReceived,
|
||||
approvalRequestsReplaced,
|
||||
backgroundProgressEventReceived,
|
||||
backgroundProgressEventRemoved,
|
||||
rpcSetBackgroundItems,
|
||||
rpcSetApprovalItems,
|
||||
} = rpcSlice.actions;
|
||||
|
||||
export default rpcSlice.reducer;
|
||||
|
|
|
|||
|
|
@ -63,10 +63,6 @@ export const walletSlice = createSlice({
|
|||
setRestoreHeight(slice, action: PayloadAction<GetRestoreHeightResponse>) {
|
||||
slice.state.restoreHeight = action.payload;
|
||||
},
|
||||
// Reset actions
|
||||
resetWalletState(slice) {
|
||||
slice.state = initialState.state;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -75,7 +71,6 @@ export const {
|
|||
setBalance,
|
||||
setSyncProgress,
|
||||
setHistory,
|
||||
resetWalletState,
|
||||
setRestoreHeight,
|
||||
} = walletSlice.actions;
|
||||
|
||||
|
|
|
|||
|
|
@ -184,11 +184,6 @@ export function useSwapInfosSortedByDate() {
|
|||
return sortBy(swapInfos, (swap) => -parseDateString(swap.start_date));
|
||||
}
|
||||
|
||||
export function useRates<T>(selector: (rates: RatesState) => T): T {
|
||||
const rates = useAppSelector((state) => state.rates);
|
||||
return selector(rates);
|
||||
}
|
||||
|
||||
export function useSettings<T>(selector: (settings: SettingsState) => T): T {
|
||||
const settings = useAppSelector((state) => state.settings);
|
||||
return selector(settings);
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export const selectPendingApprovals = createSelector(
|
|||
),
|
||||
);
|
||||
|
||||
// TODO: This should be split into multiple selectors/hooks to avoid excessive re-rendering
|
||||
// TODO: This should be split into multiple selectors/hooks to avoid excessive re-rendering
|
||||
export const selectPeers = createSelector([selectP2pState], (p2p) => {
|
||||
const peerIds = new Set([
|
||||
...Object.keys(p2p.connectionStatus),
|
||||
|
|
|
|||
|
|
@ -39,10 +39,6 @@ export function getMoneroTxExplorerUrl(txid: string, stagenet: boolean) {
|
|||
return `https://xmrchain.net/tx/${txid}`;
|
||||
}
|
||||
|
||||
export function secondsToDays(seconds: number): number {
|
||||
return seconds / 86400;
|
||||
}
|
||||
|
||||
export function bytesToMb(bytes: number): number {
|
||||
return bytes / (1024 * 1024);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,15 +14,6 @@ export function isMakerOnCorrectNetwork(
|
|||
return provider.testnet === isTestnet();
|
||||
}
|
||||
|
||||
export function isMakerOutdated(maker: ExtendedMakerStatus): boolean {
|
||||
if (maker.version != null) {
|
||||
if (isMakerVersionOutdated(maker.version)) return true;
|
||||
}
|
||||
|
||||
// Do not mark a maker as outdated if it doesn't have a version
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isMakerVersionOutdated(version: string): boolean {
|
||||
// This checks if the version is less than the minimum version
|
||||
// we use .compare(...) instead of .satisfies(...) because satisfies(...)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,6 @@
|
|||
import { CliLog, parseCliLogString } from "models/cliModel";
|
||||
import { Multiaddr } from "multiaddr";
|
||||
|
||||
/*
|
||||
Extract btc amount from string
|
||||
|
||||
E.g: "0.00100000 BTC"
|
||||
Output: 0.001
|
||||
*/
|
||||
export function extractAmountFromUnitString(text: string): number | null {
|
||||
if (text != null) {
|
||||
const parts = text.split(" ");
|
||||
if (parts.length === 2) {
|
||||
const amount = Number.parseFloat(parts[0]);
|
||||
return amount;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// E.g: 2024-08-19 6:11:37.475038 +00:00:00
|
||||
export function parseDateString(str: string): number {
|
||||
// Split the string and take only the date and time parts
|
||||
|
|
@ -74,23 +57,3 @@ export function isValidMultiAddressWithPeerId(
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// This function splits a multi address string into the multi address and peer ID components.
|
||||
// It throws an error if the multi address string is invalid or does not contain a peer ID component.
|
||||
export function splitPeerIdFromMultiAddress(
|
||||
multiAddressStr: string,
|
||||
): [multiAddress: string, peerId: string] {
|
||||
const multiAddress = new Multiaddr(multiAddressStr);
|
||||
|
||||
// Extract the peer ID
|
||||
const peerId = multiAddress.getPeerId();
|
||||
|
||||
if (peerId) {
|
||||
// Remove the peer ID component
|
||||
const p2pMultiaddr = new Multiaddr("/p2p/" + peerId);
|
||||
const multiAddressWithoutPeerId = multiAddress.decapsulate(p2pMultiaddr);
|
||||
return [multiAddressWithoutPeerId.toString(), peerId];
|
||||
} else {
|
||||
throw new Error("No peer id encapsulated in multi address");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue