started on list

This commit is contained in:
PhilReact 2025-03-11 21:02:23 +02:00
parent 84218f4fa0
commit d5f796281b
16 changed files with 2460 additions and 565 deletions

2069
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -20,13 +20,18 @@
"clean": "rm -rf dist"
},
"dependencies": {
"react": "^18.0.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.4.7",
"@mui/material": "^6.4.7",
"@tanstack/react-virtual": "^3.13.2",
"react": "^19.0.0",
"zustand": "^4.3.2"
},
"devDependencies": {
"@types/react": "^18.0.27",
"tsup": "^7.0.0",
"typescript": "^5.2.0",
"@types/react": "^18.0.27"
"typescript": "^5.2.0"
},
"repository": {
"type": "git",

43
src/common/ListLoader.tsx Normal file
View File

@ -0,0 +1,43 @@
import { Box, Typography } from "@mui/material"
import { BarSpinner } from "./Spinners/BarSpinner/BarSpinner"
interface ListLoaderProps {
isLoading: boolean;
loadingMessage?: string; // Optional message while loading
resultsLength: number;
noResultsMessage?: string; // Optional message when no results
children: React.ReactNode; // Required, to render the list content
}
export const ListLoader = ({ isLoading, loadingMessage, resultsLength, children, noResultsMessage }: ListLoaderProps) => {
return (
<>
{isLoading && (
<Box sx={{
display: 'flex',
gap: '20px',
alignItems: 'center',
overflow: "auto",
}}>
<BarSpinner width="22px" color="green" />
<Typography>{loadingMessage || `Fetching list`}</Typography>
</Box>
)}
{!isLoading && resultsLength === 0 && (
<Typography
style={{
display: "block",
}}
>
{noResultsMessage}
</Typography>
)}
{!isLoading && resultsLength > 0 && (
<>
{children}
</>
)}
</>
)
}

View File

@ -0,0 +1,10 @@
import React from 'react'
import './barSpinner.css'
export const BarSpinner = ({width = '20px', color}) => {
return (
<div style={{
width,
color: color || 'green'
}} className="loader-bar"></div>
)
}

View File

@ -0,0 +1,19 @@
/* HTML: <div class="loader"></div> */
.loader-bar {
width: 45px;
aspect-ratio: .75;
--c:no-repeat linear-gradient(currentColor 0 0);
background:
var(--c) 0% 100%,
var(--c) 50% 100%,
var(--c) 100% 100%;
background-size: 20% 65%;
animation: l8 1s infinite linear;
}
@keyframes l8 {
16.67% {background-position: 0% 0% ,50% 100%,100% 100%}
33.33% {background-position: 0% 0% ,50% 0% ,100% 100%}
50% {background-position: 0% 0% ,50% 0% ,100% 0% }
66.67% {background-position: 0% 100%,50% 0% ,100% 0% }
83.33% {background-position: 0% 100%,50% 100%,100% 0% }
}

View File

@ -0,0 +1,92 @@
import React, { CSSProperties, useCallback, useRef } from 'react'
import { useVirtualizer } from "@tanstack/react-virtual";
interface PropsVirtualizedList {
list: any[]
children: (item: any, index: number) => React.ReactNode;
}
export const VirtualizedList = ({list, children}: PropsVirtualizedList) => {
const parentRef = useRef(null);
const rowVirtualizer = useVirtualizer({
count: list.length,
getItemKey: useCallback((index: number) => (list[index]?.name && list[index]?.name) ?`${list[index].name}-${list[index].identifier}`: list[index]?.id, [list]),
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // Provide an estimated height of items, adjust this as needed
overscan: 10, // Number of items to render outside the visible area to improve smoothness
});
return (
<div
style={{
display: "flex",
width: "100%",
height: "100%",
}}
>
<div
style={{
height: "100%",
position: "relative",
display: "flex",
flexDirection: "column",
width: "100%",
}}
>
<div
ref={parentRef}
className="List"
style={{
flexGrow: 1,
overflow: "auto",
position: "relative",
display: "flex",
height: "0px",
}}
>
<div
style={{
height: rowVirtualizer.getTotalSize(),
width: "100%",
}}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const index = virtualRow.index;
const item = list[index];
return (
<div
data-index={virtualRow.index} //needed for dynamic row height measurement
ref={rowVirtualizer.measureElement} //measure dynamic row height
key={`${item.name}-${item.identifier}`}
style={{
position: "absolute",
top: 0,
left: "50%", // Move to the center horizontally
transform: `translateY(${virtualRow.start}px) translateX(-50%)`, // Adjust for centering
width: "100%", // Control width (90% of the parent)
display: "flex",
alignItems: "center",
overscrollBehavior: "none",
flexDirection: "column"
}}
>
{typeof children === "function" ? children(item, index) : null}
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,37 @@
import { Card, CardContent } from "@mui/material";
import React, { ReactNode } from "react";
interface PropsCardWrapper {
isInCart: boolean;
children: ReactNode | ReactNode[];
height?: number;
}
export const ItemCardWrapper = ({
children,
isInCart,
height,
}: PropsCardWrapper) => {
return (
<Card
sx={{
width: "100%",
height: height || "auto",
}}
>
<CardContent
sx={{
height: "100%",
display: "flex",
alignItems: "center",
gap: "10px",
p: 1,
"&:last-child": { pb: 1 },
}}
>
{children}
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,120 @@
import React, {
CSSProperties,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import {
QortalMetadata,
QortalSearchParams,
} from "../../types/interfaces/resources";
import { useResources } from "../../hooks/useResources";
import { VirtualizedList } from "../../common/VirtualizedList";
import { ListLoader } from "../../common/ListLoader";
import { ListItem, useCacheStore } from "../../state/cache";
import { ResourceLoader } from "./ResourceLoader";
import { ItemCardWrapper } from "./ItemCardWrapper";
interface PropsResourceListDisplay {
params: QortalSearchParams;
listItem: (item: ListItem, index: number) => React.ReactNode; // Function type
}
export const ResourceListDisplay = ({
params,
listItem,
}: PropsResourceListDisplay) => {
const [list, setList] = useState<QortalMetadata[]>([]);
const { fetchResources } = useResources();
const [isLoading, setIsLoading] = useState(false);
const memoizedParams = useMemo(() => JSON.stringify(params), [params]);
const getResourceList = useCallback(async () => {
try {
setIsLoading(true);
const parsedParams = JSON.parse(memoizedParams);
const res = await fetchResources(parsedParams); // Awaiting the async function
setList(res || []); // Ensure it's an array, avoid setting `undefined`
} catch (error) {
console.error("Failed to fetch resources:", error);
} finally {
setIsLoading(false);
}
}, [memoizedParams, fetchResources]); // Added dependencies for re-fetching
useEffect(() => {
getResourceList();
}, [getResourceList]); // Runs when dependencies change
return (
<ListLoader
noResultsMessage="No results available"
resultsLength={list?.length}
isLoading={isLoading}
loadingMessage="Retrieving list. Please wait."
>
<div
style={{
height: "100%",
display: "flex",
width: "100%",
}}
>
<div style={{ display: "flex", flexGrow: isLoading ? 0 : 1 }}>
<VirtualizedList list={list}>
{(item: QortalMetadata, index: number) => (
<ListItemWrapper item={item} index={index} render={listItem} />
)}
</VirtualizedList>
</div>
</div>
</ListLoader>
);
};
interface ListItemWrapperProps {
item: QortalMetadata;
index: number;
render: (item: ListItem, index: number) => React.ReactNode;
}
const ListItemWrapper: React.FC<ListItemWrapperProps> = ({
item,
index,
render,
}) => {
console.log("Rendering wrapped item:", item, index);
const getResourceCache = useCacheStore().getResourceCache;
const findCachedResource = getResourceCache(
`${item.service}-${item.name}-${item.identifier}`,
true
);
if (findCachedResource === null)
return (
<ItemCardWrapper height={60} isInCart={false}>
<ResourceLoader
message="Fetching Data..."
status="loading"
/>
</ItemCardWrapper>
);
if (findCachedResource === false)
return (
<ItemCardWrapper height={60} isInCart={false}>
<ResourceLoader
message="Product is unavailble at this moment... Try again later."
status="error"
/>
</ItemCardWrapper>
);
// Example transformation (Modify item if needed)
const transformedItem = findCachedResource
? findCachedResource
: { qortalMetadata: item };
return <>{render(transformedItem, index)}</>;
};

View File

@ -0,0 +1,44 @@
import { Box, Typography } from "@mui/material";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import { BarSpinner } from "../../common/Spinners/BarSpinner/BarSpinner";
interface PropsResourceLoader {
status?: string;
message?: string;
}
export const ResourceLoader = ({ status, message }: PropsResourceLoader) => {
return (
<Box
sx={{
display: "flex",
gap: "20px",
alignItems: "center",
justifyContent: "center",
height: "100%",
width: "100%",
}}
>
{status === "loading" && (
<>
<BarSpinner width="22px" color="green" />
<Typography>
{message || `Fetching Content...`}
</Typography>
</>
)}
{status === "error" && (
<>
<ErrorOutlineIcon
width="22px"
style={{
color: "green",
}}
/>
<Typography>
{message || `Content Unavailable Now... Please try again later.`}
</Typography>
</>
)}
</Box>
);
};

164
src/hooks/useResources.tsx Normal file
View File

@ -0,0 +1,164 @@
import React, { useCallback } from 'react'
import { QortalMetadata, QortalSearchParams } from '../types/interfaces/resources';
import { useCacheStore } from '../state/cache';
import { RequestQueueWithPromise } from '../utils/queue';
import { base64ToUint8Array, uint8ArrayToObject } from '../utils/base64';
export const requestQueueProductPublishes = new RequestQueueWithPromise(20);
export const requestQueueProductPublishesBackup = new RequestQueueWithPromise(5);
export const useResources = () => {
const { setSearchCache, getSearchCache, getResourceCache, setResourceCache } = useCacheStore();
const fetchIndividualPublish = useCallback(
async (item: QortalMetadata) => {
try {
const cachedProduct = getResourceCache(`${item?.service}-${item?.name}-${item?.identifier}`);
if (cachedProduct) return;
setResourceCache(`${item?.service}-${item?.name}-${item?.identifier}`, null);
let hasFailedToDownload = false
let res: string | undefined = undefined
try {
res = await requestQueueProductPublishes.enqueue((): Promise<string> => {
return qortalRequest({
action: "FETCH_QDN_RESOURCE",
identifier: item?.identifier,
encoding: "base64",
name: item?.name,
service: item?.service,
});
});
} catch (error) {
hasFailedToDownload = true
}
if (hasFailedToDownload) {
await new Promise((res) => {
setTimeout(() => {
res(null)
}, 15000)
})
try {
res = await requestQueueProductPublishesBackup.enqueue((): Promise<string> => {
return qortalRequest({
action: "FETCH_QDN_RESOURCE",
identifier: item?.identifier,
encoding: "base64",
name: item?.name,
service: item?.service,
});
});
} catch (error) {
setResourceCache(`${item?.service}-${item?.name}-${item?.identifier}`, false);
return false
}
}
if (res) {
const toUint = base64ToUint8Array(res);
const toObject = uint8ArrayToObject(toUint);
const fullDataObject = { data: {...toObject}, qortalMetadata: item };
setResourceCache(`${item?.service}-${item?.name}-${item?.identifier}`, fullDataObject);
return fullDataObject
}
} catch (error) {
return false
}
},
[getResourceCache, setResourceCache]
);
const fetchDataFromResults = useCallback(
(responseData: QortalMetadata[]): void => {
for (const item of responseData) {
fetchIndividualPublish(item);
}
},
[fetchIndividualPublish]
);
const fetchResources = useCallback(
async (params: QortalSearchParams): Promise<QortalMetadata[]> => {
const cacheKey = generateCacheKey(params);
const searchCache = getSearchCache(cacheKey);
let responseData = [];
if (searchCache) {
responseData = searchCache;
} else {
const response = await qortalRequest({
action: "SEARCH_QDN_RESOURCES",
mode: 'ALL',
limit: 20,
...params,
});
if (!response) throw new Error("Unable to fetch resources");
responseData = response
}
setSearchCache(cacheKey, responseData);
fetchDataFromResults(responseData);
return responseData;
},
[getSearchCache, setSearchCache, fetchDataFromResults]
);
return {
fetchResources,
fetchIndividualPublish
}
}
export const generateCacheKey = (params: QortalSearchParams): string => {
const {
identifier,
service,
query,
name,
names,
keywords,
title,
description,
prefix,
exactMatchNames,
minLevel,
nameListFilter,
followedOnly,
excludeBlocked,
before,
after,
limit,
offset,
reverse,
mode
} = params;
const keyParts = [
`catalog-${service}`,
`id-${identifier}`,
query && `q-${query}`,
name && `n-${name}`,
names && `ns-${names.join(",")}`,
keywords && `kw-${keywords.join(",")}`,
title && `t-${title}`,
description && `desc-${description}`,
prefix !== undefined && `p-${prefix}`,
exactMatchNames !== undefined && `ex-${exactMatchNames}`,
minLevel !== undefined && `ml-${minLevel}`,
nameListFilter && `nf-${nameListFilter}`,
followedOnly !== undefined && `fo-${followedOnly}`,
excludeBlocked !== undefined && `eb-${excludeBlocked}`,
before !== undefined && `b-${before}`,
after !== undefined && `a-${after}`,
limit !== undefined && `l-${limit}`,
offset !== undefined && `o-${offset}`,
reverse !== undefined && `r-${reverse}`,
mode !== undefined && `mo-${mode}`,
]
.filter(Boolean) // Remove undefined or empty values
.join("_"); // Join into a string
return keyParts;
};

7
src/index.css Normal file
View File

@ -0,0 +1,7 @@
:root {
line-height: 1.2;
padding: 0px;
margin: 0px;
box-sizing: border-box;
}

View File

@ -1 +1,3 @@
import './index.css'
export { GlobalProvider, useGlobal } from "./context/GlobalProvider";
export {ResourceListDisplay} from "./components/ResourceList/ResourceListDisplay"

113
src/state/cache.ts Normal file
View File

@ -0,0 +1,113 @@
import { create } from "zustand";
import { QortalMetadata } from "../types/interfaces/resources";
interface SearchCache {
[searchTerm: string]: {
data: QortalMetadata[]; // List of products for the search term
expiry: number; // Expiry timestamp in milliseconds
};
}
export const mergeUniqueItems = (array1: QortalMetadata[], array2: QortalMetadata[]) => {
const mergedArray = [...array1, ...array2];
// Use a Map to ensure uniqueness based on `identifier-name`
const uniqueMap = new Map();
mergedArray.forEach(item => {
if (item.identifier && item.name && item.service) {
const key = `${item.service}-${item.name}-${item.identifier}`;
uniqueMap.set(key, item);
}
});
return Array.from(uniqueMap.values()); // Return the unique values
};
export interface ListItem {
data?: any
qortalMetadata: QortalMetadata
}
interface resourceCache {
[id: string]: {
data: ListItem | false | null; // Cached resource data
expiry: number; // Expiry timestamp in milliseconds
};
}
interface CacheState {
resourceCache: resourceCache;
searchCache: SearchCache;
// Search cache actions
setResourceCache: (id: string, data: ListItem | false | null, customExpiry?: number) => void;
setSearchCache: (searchTerm: string, data: QortalMetadata[], customExpiry?: number) => void;
getSearchCache: (searchTerm: string) => QortalMetadata[] | null;
clearExpiredCache: () => void;
getResourceCache: (id: string, ignoreExpire?: boolean) => ListItem | false | null;
}
export const useCacheStore = create<CacheState>((set, get) => ({
resourceCache: {},
searchCache: {},
orderCache: {},
messageCache: {},
getResourceCache: (id, ignoreExpire) => {
const cache = get().resourceCache[id];
if (cache && (cache.expiry > Date.now() || ignoreExpire)) {
return cache.data; // Return cached product if not expired
}
return null; // Cache expired or doesn't exist
},
setResourceCache: (id, data, customExpiry) =>
set((state) => {
const expiry = Date.now() + (customExpiry || (30 * 60 * 1000)); // 30mins from now
return {
resourceCache: {
...state.resourceCache,
[id]: { data, expiry },
},
};
}),
// Add search results to cache
setSearchCache: (searchTerm, data, customExpiry) =>
set((state) => {
const expiry = Date.now() + (customExpiry || (5 * 60 * 1000)); // 5mins from now
return {
searchCache: {
...state.searchCache,
[searchTerm]: { data, expiry },
},
};
}),
// Retrieve cached search results
getSearchCache: (searchTerm) => {
const cache = get().searchCache[searchTerm];
if (cache && cache.expiry > Date.now()) {
return cache.data; // Return cached search results if not expired
}
return null; // Cache expired or doesn't exist
},
// Clear expired caches
clearExpiredCache: () =>
set((state) => {
const now = Date.now();
// Filter expired searches
const validSearchCache = Object.fromEntries(
Object.entries(state.searchCache).filter(
([, value]) => value.expiry > now
)
);
return {
searchCache: validSearchCache,
};
}),
}));

View File

@ -0,0 +1,87 @@
export type Service =
| "ARBITRARY_DATA"
| "QCHAT_ATTACHMENT"
| "ATTACHMENT"
| "FILE"
| "FILES"
| "CHAIN_DATA"
| "WEBSITE"
| "IMAGE"
| "THUMBNAIL"
| "QCHAT_IMAGE"
| "VIDEO"
| "AUDIO"
| "QCHAT_AUDIO"
| "QCHAT_VOICE"
| "VOICE"
| "PODCAST"
| "BLOG"
| "BLOG_POST"
| "BLOG_COMMENT"
| "DOCUMENT"
| "LIST"
| "PLAYLIST"
| "APP"
| "METADATA"
| "JSON"
| "GIF_REPOSITORY"
| "STORE"
| "PRODUCT"
| "OFFER"
| "COUPON"
| "CODE"
| "PLUGIN"
| "EXTENSION"
| "GAME"
| "ITEM"
| "NFT"
| "DATABASE"
| "SNAPSHOT"
| "COMMENT"
| "CHAIN_COMMENT"
| "MAIL"
| "MESSAGE"
// Newly added private types
| "QCHAT_ATTACHMENT_PRIVATE"
| "ATTACHMENT_PRIVATE"
| "FILE_PRIVATE"
| "IMAGE_PRIVATE"
| "VIDEO_PRIVATE"
| "AUDIO_PRIVATE"
| "VOICE_PRIVATE"
| "DOCUMENT_PRIVATE"
| "MAIL_PRIVATE"
| "MESSAGE_PRIVATE";
export interface QortalMetadata {
size: number
created: number
name: string
identifier: string
service: Service
}
export interface QortalSearchParams {
identifier: string;
service: Service;
query?: string;
name?: string;
names?: string[];
keywords?: string[];
title?: string;
description?: string;
prefix?: boolean;
exactMatchNames?: boolean;
minLevel?: number;
nameListFilter?: string;
followedOnly?: boolean;
excludeBlocked?: boolean;
before?: number;
after?: number;
limit?: number;
offset?: number;
reverse?: boolean;
mode?: 'ALL' | 'LATEST'
}

121
src/utils/base64.ts Normal file
View File

@ -0,0 +1,121 @@
class Semaphore {
private count: number;
private waiting: (() => void)[];
constructor(count: number) {
this.count = count;
this.waiting = [];
}
acquire(): Promise<void> {
return new Promise((resolve) => {
if (this.count > 0) {
this.count--;
resolve();
} else {
this.waiting.push(resolve);
}
});
}
release(): void {
if (this.waiting.length > 0) {
const resolve = this.waiting.shift();
if (resolve) resolve();
} else {
this.count++;
}
}
}
const semaphore = new Semaphore(1)
export const fileToBase64 = (file : File): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader(); // Create a new instance
semaphore.acquire();
reader.readAsDataURL(file);
reader.onload = () => {
const dataUrl = reader.result;
semaphore.release();
if (typeof dataUrl === 'string') {
resolve(dataUrl.split(',')[1]);
} else {
reject(new Error('Invalid data URL'));
}
reader.onload = null; // Clear the handler
reader.onerror = null; // Clear the handle
};
reader.onerror = (error) => {
semaphore.release();
reject(error);
reader.onload = null; // Clear the handler
reader.onerror = null; // Clear the handle
};
});
export function objectToBase64(obj: object): Promise<string> {
// Step 1: Convert the object to a JSON string
const jsonString = JSON.stringify(obj)
// Step 2: Create a Blob from the JSON string
const blob = new Blob([jsonString], { type: 'application/json' })
// Step 3: Create a FileReader to read the Blob as a base64-encoded string
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
if (typeof reader.result === 'string') {
// Remove 'data:application/json;base64,' prefix
const base64 = reader.result.replace(
'data:application/json;base64,',
''
)
resolve(base64)
} else {
reject(new Error('Failed to read the Blob as a base64-encoded string'))
}
}
reader.onerror = () => {
reject(reader.error)
}
reader.readAsDataURL(blob)
})
}
export function base64ToUint8Array(base64: string) {
const binaryString = atob(base64)
const len = binaryString.length
const bytes = new Uint8Array(len)
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return bytes
}
export function uint8ArrayToBase64(uint8Array: Uint8Array) {
const length = uint8Array.length
let binaryString = ''
const chunkSize = 1024 * 1024; // Process 1MB at a time
for (let i = 0; i < length; i += chunkSize) {
const chunkEnd = Math.min(i + chunkSize, length)
const chunk = uint8Array.subarray(i, chunkEnd)
binaryString += Array.from(chunk, byte => String.fromCharCode(byte)).join('')
}
return btoa(binaryString)
}
export function uint8ArrayToObject(uint8Array: Uint8Array) {
// Decode the byte array using TextDecoder
const decoder = new TextDecoder()
const jsonString = decoder.decode(uint8Array)
// Convert the JSON string back into an object
return JSON.parse(jsonString)
}
export function base64ToObject(base64: string){
const toUint = base64ToUint8Array(base64);
const toObject = uint8ArrayToObject(toUint);
return toObject
}

86
src/utils/queue.ts Normal file
View File

@ -0,0 +1,86 @@
type RequestFunction<T> = () => Promise<T>;
interface QueueItem<T> {
request: RequestFunction<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: any) => void;
}
export class RequestQueueWithPromise<T = any> {
private queue: QueueItem<T>[] = [];
private maxConcurrent: number;
private currentlyProcessing: number = 0;
private isPaused: boolean = false;
constructor(maxConcurrent: number = 5) {
this.maxConcurrent = maxConcurrent;
}
// Add a request to the queue and return a promise
enqueue(request: RequestFunction<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.queue.push({ request, resolve, reject });
this.process();
});
}
// Process requests in the queue
private async process(): Promise<void> {
// Process requests only if the queue is not paused
if (this.isPaused) return;
while (this.queue.length > 0 && this.currentlyProcessing < this.maxConcurrent) {
this.currentlyProcessing++;
const { request, resolve, reject } = this.queue.shift()!; // Non-null assertion because length > 0
try {
const response = await request();
resolve(response);
} catch (error) {
reject(error);
} finally {
this.currentlyProcessing--;
await this.process(); // Continue processing the queue
}
}
}
// Pause the queue processing
pause(): void {
this.isPaused = true;
}
// Resume the queue processing
resume(): void {
this.isPaused = false;
this.process(); // Continue processing when resumed
}
// Clear pending requests in the queue
clear(): void {
this.queue.length = 0;
}
}
export async function retryTransaction(fn, args, throwError, retries) {
let attempt = 0;
while (attempt < retries) {
try {
return await fn(...args);
} catch (error) {
console.error(`Attempt ${attempt + 1} failed: ${error.message}`);
attempt++;
if (attempt === retries) {
console.error("Max retries reached. Skipping transaction.");
if(throwError){
throw new Error(error?.message || "Unable to process transaction")
} else {
return null
}
}
await new Promise(res => setTimeout(res, 10000));
}
}
}