diff --git a/src/components/ResourceList/HorizontalPaginationList.tsx b/src/components/ResourceList/HorizontalPaginationList.tsx index 78a92ab..fdaa3e3 100644 --- a/src/components/ResourceList/HorizontalPaginationList.tsx +++ b/src/components/ResourceList/HorizontalPaginationList.tsx @@ -77,7 +77,7 @@ const displayedItems = disablePagination ? items : items?.length < (displayedLim ))} > - + {!disablePagination && displayedItems?.length <= (displayedLimit * 3) && ( { await onLoadMore(displayedLimit); @@ -88,7 +88,7 @@ const displayedItems = disablePagination ? items : items?.length < (displayedLim }} /> - + )} diff --git a/src/components/ResourceList/ResourceListDisplay.tsx b/src/components/ResourceList/ResourceListDisplay.tsx index fa729f1..55a20a0 100644 --- a/src/components/ResourceList/ResourceListDisplay.tsx +++ b/src/components/ResourceList/ResourceListDisplay.tsx @@ -50,6 +50,8 @@ export interface DefaultLoaderParams { listItemErrorText?: string; } +export type ReturnType = 'JSON' | 'BASE64' + interface BaseProps { search: QortalSearchParams; entityParams?: EntityParams; @@ -66,7 +68,8 @@ interface BaseProps { resourceCacheDuration?: number disablePagination?: boolean disableScrollTracker?: boolean - retryAttempts: number + retryAttempts: number, + returnType: 'JSON' | 'BASE64' } // ✅ Restrict `direction` only when `disableVirtualization = false` @@ -101,6 +104,7 @@ export const MemorizedComponent = ({ disablePagination, disableScrollTracker, entityParams, + returnType = 'JSON', retryAttempts = 2 }: PropsResourceListDisplay) => { const { filterOutDeletedResources } = useCacheStore(); @@ -152,7 +156,6 @@ export const MemorizedComponent = ({ const getResourceList = useCallback(async () => { try { - if(!generatedIdentifier) return await new Promise((res)=> { @@ -163,7 +166,7 @@ export const MemorizedComponent = ({ setIsLoading(true); const parsedParams = {...(JSON.parse(memoizedParams))}; parsedParams.identifier = generatedIdentifier - const responseData = await lists.fetchResources(parsedParams, listName, true); // Awaiting the async function + const responseData = await lists.fetchResources(parsedParams, listName, returnType, true); // Awaiting the async function @@ -221,7 +224,7 @@ export const MemorizedComponent = ({ if(displayLimit){ parsedParams.limit = displayLimit } - const responseData = await lists.fetchResources(parsedParams, listName); // Awaiting the async function + const responseData = await lists.fetchResources(parsedParams, listName, returnType); // Awaiting the async function addItems(listName, responseData || []) } catch (error) { console.error("Failed to fetch resources:", error); diff --git a/src/components/ResourceList/VerticalPaginationList.tsx b/src/components/ResourceList/VerticalPaginationList.tsx index aa3dfa6..998ddfa 100644 --- a/src/components/ResourceList/VerticalPaginationList.tsx +++ b/src/components/ResourceList/VerticalPaginationList.tsx @@ -1,4 +1,10 @@ -import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import React, { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import DynamicGrid from "./DynamicGrid"; import LazyLoad from "../../common/LazyLoad"; import { ListItem } from "../../state/cache"; @@ -11,9 +17,9 @@ interface VerticalPaginatedListProps { listItem: (item: ListItem, index: number) => React.ReactNode; loaderItem?: (status: "LOADING" | "ERROR") => React.ReactNode; onLoadMore: (limit: number) => void; - onLoadLess: (limit: number)=> void; - limit: number, - disablePagination?: boolean + onLoadLess: (limit: number) => void; + limit: number; + disablePagination?: boolean; defaultLoaderParams?: DefaultLoaderParams; } @@ -25,63 +31,78 @@ export const VerticalPaginatedList = ({ onLoadLess, limit, disablePagination, - defaultLoaderParams + defaultLoaderParams, }: VerticalPaginatedListProps) => { + const lastItemRef = useRef(null); + const lastItemRef2 = useRef(null); -const lastItemRef= useRef(null) -const lastItemRef2= useRef(null) + const displayedLimit = limit || 20; -const displayedLimit = limit || 20 - -const displayedItems = disablePagination ? items : items.slice(- (displayedLimit * 3)) + const displayedItems = disablePagination + ? items + : items.slice(-(displayedLimit * 3)); return ( <> - {!disablePagination && items?.length > (displayedLimit * 3) && ( - { + {!disablePagination && items?.length > displayedLimit * 3 && ( + { + await onLoadLess(displayedLimit); + lastItemRef2.current.scrollIntoView({ + behavior: "auto", + block: "start", + }); + setTimeout(() => { + window.scrollBy({ top: -50, behavior: "instant" }); // 'smooth' if needed + }, 0); + }} + /> + )} - await onLoadLess(displayedLimit); - lastItemRef2.current.scrollIntoView({ behavior: "auto", block: "start" }); - setTimeout(() => { - window.scrollBy({ top: -50, behavior: "instant" }); // 'smooth' if needed - }, 0); - }} - /> - )} - - {displayedItems?.map((item, index, list) => { - return ( - -
- -
-
- ); - })} - - { - await onLoadMore(displayedLimit); - lastItemRef.current.scrollIntoView({ behavior: "auto", block: "end" }); - setTimeout(() => { - window.scrollBy({ top: 50, behavior: "instant" }); // 'smooth' if needed - }, 0); - - }} - /> + {displayedItems?.map((item, index, list) => { + return ( + +
+ +
+
+ ); + })} + {!disablePagination && displayedItems?.length <= (displayedLimit * 3) && ( + { + await onLoadMore(displayedLimit); + lastItemRef.current.scrollIntoView({ + behavior: "auto", + block: "end", + }); + setTimeout(() => { + window.scrollBy({ top: 50, behavior: "instant" }); // 'smooth' if needed + }, 0); + }} + /> + )} ); }; diff --git a/src/context/GlobalProvider.tsx b/src/context/GlobalProvider.tsx index e6d5e2d..a361e8c 100644 --- a/src/context/GlobalProvider.tsx +++ b/src/context/GlobalProvider.tsx @@ -6,6 +6,7 @@ import { addAndEncryptSymmetricKeys, decryptWithSymmetricKeys, encryptWithSymmet import { useIdentifiers } from "../hooks/useIdentifiers"; import { objectToBase64 } from "../utils/base64"; import { base64ToObject } from "../utils/publish"; +import { generateBloomFilterBase64, isInsideBloom } from "../utils/bloomFilter"; const utils = { @@ -13,7 +14,9 @@ const utils = { base64ToObject, addAndEncryptSymmetricKeys, encryptWithSymmetricKeys, - decryptWithSymmetricKeys + decryptWithSymmetricKeys, + generateBloomFilterBase64, + isInsideBloom } diff --git a/src/hooks/useIdentifiers.tsx b/src/hooks/useIdentifiers.tsx index a1edda9..3fc1e1f 100644 --- a/src/hooks/useIdentifiers.tsx +++ b/src/hooks/useIdentifiers.tsx @@ -30,6 +30,12 @@ export const useIdentifiers = (builder?: IdentifierBuilder, publicSalt?: string) const appNameHashed = await hashWord(appName, EnumCollisionStrength.HIGH, publicSalt) return appNameHashed + '_' + partialIdentifier }, [appName, publicSalt]) + + const hashQortalName = useCallback(async ( qortalName: string)=> { + if(!qortalName || !publicSalt) return null + const hashedQortalName = await hashWord(qortalName, EnumCollisionStrength.HIGH, publicSalt) + return hashedQortalName +}, [publicSalt]) useEffect(()=> { @@ -40,6 +46,7 @@ export const useIdentifiers = (builder?: IdentifierBuilder, publicSalt?: string) return { buildIdentifier: buildIdentifierFunc, buildSearchPrefix: buildSearchPrefixFunc, - createSingleIdentifier + createSingleIdentifier, + hashQortalName }; }; diff --git a/src/hooks/useNameSearch.tsx b/src/hooks/useNameSearch.tsx new file mode 100644 index 0000000..a00919b --- /dev/null +++ b/src/hooks/useNameSearch.tsx @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useState } from "react"; + + +interface NameListItem { + name: string + address: string +} +export const useNameSearch = (value: string, limit = 20) => { + const [nameList, setNameList] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const checkIfNameExisits = useCallback( + async (name: string, listLimit: number) => { + try { + if(!name){ + setNameList([]) + return + } + setIsLoading(true); + const res = await fetch( + `/names/search?query=${name}&prefix=true&limit=${listLimit}` + ); + const data = await res.json(); + setNameList(data?.map((item: any)=> { + return { + name: item.name, + address: item.owner + } + })); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + }, + [] + ); + // Debounce logic + useEffect(() => { + const handler = setTimeout(() => { + checkIfNameExisits(value, limit); + }, 500); + + // Cleanup timeout if searchValue changes before the timeout completes + return () => { + clearTimeout(handler); + }; + }, [value, limit]); + return { + isLoading, + results: nameList, + }; +}; diff --git a/src/hooks/usePublish.tsx b/src/hooks/usePublish.tsx index dcd42ee..7e4cf23 100644 --- a/src/hooks/usePublish.tsx +++ b/src/hooks/usePublish.tsx @@ -3,6 +3,7 @@ import { usePublishStore } from "../state/publishes"; import { QortalGetMetadata, QortalMetadata } from "../types/interfaces/resources"; import { base64ToObject, retryTransaction } from "../utils/publish"; import { useGlobal } from "../context/GlobalProvider"; +import { ReturnType } from "../components/ResourceList/ResourceListDisplay"; const STORAGE_EXPIRY_DURATION = 5 * 60 * 1000; interface StoredPublish { @@ -12,7 +13,7 @@ interface StoredPublish { } export const usePublish = ( maxFetchTries: number = 3, - returnType: "PUBLIC_JSON" = "PUBLIC_JSON", + returnType: ReturnType = "JSON", metadata?: QortalGetMetadata ) => { const {auth, appInfo} = useGlobal() @@ -30,8 +31,11 @@ export const usePublish = ( const url = `/arbitrary/${item?.service}/${item?.name}/${item?.identifier}?encoding=base64`; const res = await fetch(url); const data = await res.text(); + if(returnType === 'BASE64'){ + return data + } return base64ToObject(data); - }, []); + }, [returnType]); const getStorageKey = useCallback(() => { if (!username || !appNameHashed) return null; @@ -66,7 +70,7 @@ export const usePublish = ( const fetchPublish = useCallback( async ( metadataProp: QortalGetMetadata, - returnTypeProp: "PUBLIC_JSON" = "PUBLIC_JSON" + returnTypeProp: ReturnType = "JSON" ) => { let resourceExists = null; let resource = null; diff --git a/src/hooks/useResources.tsx b/src/hooks/useResources.tsx index b526f9f..c2c75d1 100644 --- a/src/hooks/useResources.tsx +++ b/src/hooks/useResources.tsx @@ -7,13 +7,14 @@ import { ListItem, useCacheStore } from "../state/cache"; import { RequestQueueWithPromise } from "../utils/queue"; import { base64ToUint8Array, uint8ArrayToObject } from "../utils/base64"; import { retryTransaction } from "../utils/publish"; +import { ReturnType } from "../components/ResourceList/ResourceListDisplay"; export const requestQueueProductPublishes = new RequestQueueWithPromise(20); export const requestQueueProductPublishesBackup = new RequestQueueWithPromise( 10 ); -export interface TemporaryResource { +export interface Resource { qortalMetadata: QortalMetadata; data: any; } @@ -66,6 +67,7 @@ export const useResources = (retryAttempts: number = 2) => { const fetchIndividualPublishJson = useCallback( async ( item: QortalMetadata, + returnType: ReturnType, includeMetadata?: boolean ): Promise => { try { @@ -150,6 +152,17 @@ export const useResources = (retryAttempts: number = 2) => { } } if (res) { + if(returnType === 'BASE64'){ + const fullDataObject = { + data: res, + qortalMetadata: includeMetadata ? metadata : item, + }; + setResourceCache( + `${item?.service}-${item?.name}-${item?.identifier}`, + fullDataObject + ); + return fullDataObject; + } const toUint = base64ToUint8Array(res); const toObject = uint8ArrayToObject(toUint); const fullDataObject = { @@ -170,9 +183,9 @@ export const useResources = (retryAttempts: number = 2) => { ); const fetchDataFromResults = useCallback( - (responseData: QortalMetadata[]): void => { + (responseData: QortalMetadata[], returnType: ReturnType): void => { for (const item of responseData) { - fetchIndividualPublishJson(item, false); + fetchIndividualPublishJson(item, returnType, false); } }, [fetchIndividualPublishJson] @@ -182,7 +195,8 @@ export const useResources = (retryAttempts: number = 2) => { async ( params: QortalSearchParams, listName: string, - cancelRequests?: boolean + returnType: ReturnType = 'JSON', + cancelRequests?: boolean, ): Promise => { if (cancelRequests) { cancelAllRequests(); @@ -226,7 +240,7 @@ export const useResources = (retryAttempts: number = 2) => { } setSearchCache(listName, cacheKey, filteredResults); - fetchDataFromResults(filteredResults); + fetchDataFromResults(filteredResults, returnType); return filteredResults; }, @@ -234,7 +248,7 @@ export const useResources = (retryAttempts: number = 2) => { ); const addNewResources = useCallback( - (listName: string, resources: TemporaryResource[]) => { + (listName: string, resources: Resource[]) => { addTemporaryResource( listName, @@ -250,7 +264,7 @@ export const useResources = (retryAttempts: number = 2) => { [] ); - const updateNewResources = useCallback((resources: TemporaryResource[]) => { + const updateNewResources = useCallback((resources: Resource[]) => { resources.forEach((temporaryResource) => { setResourceCache( `${temporaryResource?.qortalMetadata?.service}-${temporaryResource?.qortalMetadata?.name}-${temporaryResource?.qortalMetadata?.identifier}`, diff --git a/src/index.ts b/src/index.ts index 25665bd..28eca9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,10 @@ import './index.css' +export { RequestQueueWithPromise } from './utils/queue'; export { GlobalProvider, useGlobal } from "./context/GlobalProvider"; export {usePublish} from "./hooks/usePublish" export {ResourceListDisplay} from "./components/ResourceList/ResourceListDisplay" export {QortalSearchParams} from './types/interfaces/resources' export {ImagePicker} from './common/ImagePicker' +export {useNameSearch} from './hooks/useNameSearch' +export {Resource} from './hooks/useResources' + diff --git a/src/state/publishes.ts b/src/state/publishes.ts index a5f4520..8c06cd8 100644 --- a/src/state/publishes.ts +++ b/src/state/publishes.ts @@ -1,17 +1,17 @@ import { create } from "zustand"; import { QortalGetMetadata } from "../types/interfaces/resources"; -import { TemporaryResource } from "../hooks/useResources"; +import { Resource } from "../hooks/useResources"; interface PublishCache { - data: TemporaryResource | null; + data: Resource | null; expiry: number; } interface PublishState { publishes: Record; - getPublish: (qortalGetMetadata: QortalGetMetadata | null, ignoreExpire?: boolean) => TemporaryResource | null; - setPublish: (qortalGetMetadata: QortalGetMetadata, data: TemporaryResource | null, customExpiry?: number) => void; + getPublish: (qortalGetMetadata: QortalGetMetadata | null, ignoreExpire?: boolean) => Resource | null; + setPublish: (qortalGetMetadata: QortalGetMetadata, data: Resource | null, customExpiry?: number) => void; clearExpiredPublishes: () => void; publishExpiryDuration: number; // Default expiry duration } diff --git a/src/utils/bloomFilter.ts b/src/utils/bloomFilter.ts index 300f1a2..3d324da 100644 --- a/src/utils/bloomFilter.ts +++ b/src/utils/bloomFilter.ts @@ -1,48 +1,54 @@ -import { BloomFilter } from 'bloom-filters'; import { base64ToObject } from './base64'; +import { Buffer } from 'buffer' +// Polyfill Buffer first +if (!(globalThis as any).Buffer) { + ;(globalThis as any).Buffer = Buffer +} -export async function hashPublicKey(publicKeyString: string) { - const encoder = new TextEncoder(); - const data = encoder.encode(publicKeyString); - - const digest = await crypto.subtle.digest("SHA-256", data); - const hashBytes = new Uint8Array(digest); - return Array.from(hashBytes) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); - } - - - -export async function generateBloomFilterBase64(publicKeys: string[]) { - if (publicKeys.length > 100) throw new Error("Max 100 users allowed"); - - const bloom = BloomFilter.create(100, 0.0004); // ~0.04% FPR - - for (const pk of publicKeys) { - const hash = await hashPublicKey(pk); - bloom.add(hash); - } - - // Serialize to compact form - const byteArray = new Uint8Array(bloom.saveAsJSON()._data); - const base64 = btoa(String.fromCharCode(...byteArray)); - - if (byteArray.length > 230) { - throw new Error(`Bloom filter exceeds 230 bytes: ${byteArray.length}`); - } - - return base64; +async function getBloomFilter() { + const { BloomFilter } = await import('bloom-filters') + return BloomFilter } -export async function userCanProbablyDecrypt(base64Bloom: string, userPublicKey: string) { - const base64ToJson = base64ToObject(base64Bloom) + + + export async function generateBloomFilterBase64(values: string[]) { + const maxItems = 100 + if (values.length > maxItems) { + throw new Error(`Max ${maxItems} items allowed`) + } + + // Create filter for the expected number of items and desired false positive rate + const BloomFilter = await getBloomFilter() + const bloom = BloomFilter.create(values.length, 0.025) // ~0.04% FPR + + for (const value of values) { + bloom.add(value) + } + + // Convert filter to JSON, then to base64 + const json = bloom.saveAsJSON() + const jsonString = JSON.stringify(json) + const base64 = Buffer.from(jsonString).toString('base64') + + const size = Buffer.byteLength(jsonString) + if (size > 238) { + throw new Error(`Bloom filter exceeds 230 bytes: ${size}`) + } + + return base64 + } + + +export async function isInsideBloom(base64Bloom: string, userPublicKey: string) { + const base64ToJson = base64ToObject(base64Bloom) + const BloomFilter = await getBloomFilter() const bloom = BloomFilter.fromJSON(base64ToJson) - const hash = await hashPublicKey(userPublicKey); - return bloom.has(hash); + + return bloom.has(userPublicKey); } \ No newline at end of file diff --git a/src/utils/encryption.ts b/src/utils/encryption.ts index 19f732b..bab7bf4 100644 --- a/src/utils/encryption.ts +++ b/src/utils/encryption.ts @@ -157,7 +157,7 @@ const getPublicKeysByNames = async (names: string[]) => { try { const response = await fetch(`/names/${name}`); const nameInfo = await response.json(); - const resAddress = await fetch(`/addresses/${nameInfo}`); + const resAddress = await fetch(`/addresses/${nameInfo.owner}`); const resData = await resAddress.json(); return resData.publicKey; } catch (error) { @@ -186,7 +186,6 @@ export const addAndEncryptSymmetricKeys = async ({ .map(Number) ); } - const groupmemberPublicKeys = await getPublicKeysByNames(names); const symmetricKey = createSymmetricKeyAndNonce(); const nextNumber = highestKey + 1;