From 770080b942727c4b7f42b53098148a8c355b9dda Mon Sep 17 00:00:00 2001 From: PhilReact Date: Thu, 20 Mar 2025 21:18:34 +0200 Subject: [PATCH] updated usePublish --- package.json | 2 +- .../ResourceList/ResourceListDisplay.tsx | 32 +-- src/context/GlobalProvider.tsx | 10 +- src/hooks/useAppInfo.tsx | 2 +- src/hooks/useIdentifiers.tsx | 11 +- src/hooks/usePublish.tsx | 184 +++++++++++++++++- src/hooks/useResources.tsx | 5 +- src/index.ts | 3 +- src/state/publishes.ts | 82 +++++--- 9 files changed, 267 insertions(+), 64 deletions(-) diff --git a/package.json b/package.json index f801656..c857c9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qapp-core", - "version": "1.0.7", + "version": "1.0.9", "description": "Qortal's core React library with global state, UI components, and utilities", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/src/components/ResourceList/ResourceListDisplay.tsx b/src/components/ResourceList/ResourceListDisplay.tsx index c99e8f2..fa729f1 100644 --- a/src/components/ResourceList/ResourceListDisplay.tsx +++ b/src/components/ResourceList/ResourceListDisplay.tsx @@ -103,9 +103,8 @@ export const MemorizedComponent = ({ entityParams, retryAttempts = 2 }: PropsResourceListDisplay) => { - const { fetchResources } = useResources(retryAttempts); const { filterOutDeletedResources } = useCacheStore(); - const {identifierOperations} = useGlobal() + const {identifierOperations, lists} = useGlobal() const deletedResources = useCacheStore().deletedResources const memoizedParams = useMemo(() => JSON.stringify(search), [search]); const addList = useListStore().addList @@ -119,9 +118,7 @@ export const MemorizedComponent = ({ const [isLoadingMore, setIsLoadingMore] = useState(false) const initialized = useRef(false) const [generatedIdentifier, setGeneratedIdentifier] = useState("") - - - + const prevGeneratedIdentifierRef = useRef('') const stringifiedEntityParams = useMemo(()=> { @@ -157,6 +154,7 @@ export const MemorizedComponent = ({ try { if(!generatedIdentifier) return + await new Promise((res)=> { setTimeout(() => { res(null) @@ -165,27 +163,29 @@ export const MemorizedComponent = ({ setIsLoading(true); const parsedParams = {...(JSON.parse(memoizedParams))}; parsedParams.identifier = generatedIdentifier - const responseData = await fetchResources(parsedParams, listName, true); // Awaiting the async function + const responseData = await lists.fetchResources(parsedParams, listName, true); // Awaiting the async function addList(listName, responseData || []); - + } catch (error) { console.error("Failed to fetch resources:", error); } finally { setIsLoading(false); } - }, [memoizedParams, fetchResources, generatedIdentifier]); // Added dependencies for re-fetching + }, [memoizedParams, lists.fetchResources, generatedIdentifier]); // Added dependencies for re-fetching useEffect(() => { - if(initialized.current || !generatedIdentifier) return - initialized.current = true - if(!isListExpired) { - setIsLoading(false) - return - } - + if(!generatedIdentifier) return + + if(!isListExpired && !initialized.current) { + setIsLoading(false) + initialized.current = true + return + } + sessionStorage.removeItem(`scroll-position-${listName}`); + prevGeneratedIdentifierRef.current = generatedIdentifier getResourceList(); }, [getResourceList, isListExpired, generatedIdentifier]); // Runs when dependencies change @@ -221,7 +221,7 @@ export const MemorizedComponent = ({ if(displayLimit){ parsedParams.limit = displayLimit } - const responseData = await fetchResources(parsedParams, listName); // Awaiting the async function + const responseData = await lists.fetchResources(parsedParams, listName); // Awaiting the async function addItems(listName, responseData || []) } catch (error) { console.error("Failed to fetch resources:", error); diff --git a/src/context/GlobalProvider.tsx b/src/context/GlobalProvider.tsx index f9beb65..7112859 100644 --- a/src/context/GlobalProvider.tsx +++ b/src/context/GlobalProvider.tsx @@ -5,17 +5,19 @@ import { useAppInfo } from "../hooks/useAppInfo"; import { IdentifierBuilder } from "../utils/encryption"; import { useIdentifiers } from "../hooks/useIdentifiers"; import { objectToBase64 } from "../utils/base64"; +import { base64ToObject } from "../utils/publish"; const utils = { - objectToBase64 + objectToBase64, + base64ToObject } // ✅ Define Global Context Type interface GlobalContextType { auth: ReturnType; -resources: ReturnType; +lists: ReturnType; appInfo: ReturnType; identifierOperations: ReturnType utils: typeof utils @@ -44,11 +46,11 @@ export const GlobalProvider = ({ children, config, identifierBuilder }: GlobalPr // ✅ Call hooks and pass in options dynamically const auth = useAuth(config?.auth || {}); const appInfo = useAppInfo(config?.appName, config?.publicSalt) - const resources = useResources() + const lists = useResources() const identifierOperations = useIdentifiers(identifierBuilder, config?.publicSalt) // ✅ Merge all hooks into a single `contextValue` - const contextValue = useMemo(() => ({ auth, resources, appInfo, identifierOperations, utils }), [auth, resources, appInfo, identifierOperations]); + const contextValue = useMemo(() => ({ auth, lists, appInfo, identifierOperations, utils }), [auth, lists, appInfo, identifierOperations]); return ( {children} diff --git a/src/hooks/useAppInfo.tsx b/src/hooks/useAppInfo.tsx index 1f3707c..f415489 100644 --- a/src/hooks/useAppInfo.tsx +++ b/src/hooks/useAppInfo.tsx @@ -10,7 +10,7 @@ export const useAppInfo = (appName?: string, publicSalt?: string) => { const handleAppInfoSetup = useCallback(async (name: string, salt: string)=> { - const appNameHashed = await hashWord(name, EnumCollisionStrength.LOW, salt) + const appNameHashed = await hashWord(name, EnumCollisionStrength.HIGH, salt) setAppState({ appName: name, publicSalt: salt, diff --git a/src/hooks/useIdentifiers.tsx b/src/hooks/useIdentifiers.tsx index 9ddff01..a1edda9 100644 --- a/src/hooks/useIdentifiers.tsx +++ b/src/hooks/useIdentifiers.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAuthStore } from "../state/auth"; import { useAppStore } from "../state/app"; -import { buildIdentifier, buildSearchPrefix, IdentifierBuilder } from "../utils/encryption"; +import { buildIdentifier, buildSearchPrefix, EnumCollisionStrength, hashWord, IdentifierBuilder } from "../utils/encryption"; export const useIdentifiers = (builder?: IdentifierBuilder, publicSalt?: string) => { @@ -25,7 +25,11 @@ export const useIdentifiers = (builder?: IdentifierBuilder, publicSalt?: string) return buildSearchPrefix(appName, publicSalt, entityType, parentId, identifierBuilder) }, [appName, publicSalt, identifierBuilder]) - + const createSingleIdentifier = useCallback(async ( partialIdentifier: string)=> { + if(!partialIdentifier || !appName || !publicSalt) return null + const appNameHashed = await hashWord(appName, EnumCollisionStrength.HIGH, publicSalt) + return appNameHashed + '_' + partialIdentifier + }, [appName, publicSalt]) useEffect(()=> { @@ -35,6 +39,7 @@ export const useIdentifiers = (builder?: IdentifierBuilder, publicSalt?: string) }, [stringifiedBuilder]) return { buildIdentifier: buildIdentifierFunc, - buildSearchPrefix: buildSearchPrefixFunc + buildSearchPrefix: buildSearchPrefixFunc, + createSingleIdentifier }; }; diff --git a/src/hooks/usePublish.tsx b/src/hooks/usePublish.tsx index 2de21db..7d90c49 100644 --- a/src/hooks/usePublish.tsx +++ b/src/hooks/usePublish.tsx @@ -1,20 +1,31 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { usePublishStore } from "../state/publishes"; -import { QortalGetMetadata } from "../types/interfaces/resources"; +import { QortalGetMetadata, QortalMetadata } from "../types/interfaces/resources"; import { base64ToObject, retryTransaction } from "../utils/publish"; +import { useGlobal } from "../context/GlobalProvider"; - +const STORAGE_EXPIRY_DURATION = 5 * 60 * 1000; +interface StoredPublish { + qortalMetadata: QortalMetadata; + data: any; + timestamp: number; + } export const usePublish = ( maxFetchTries: number = 3, returnType: "PUBLIC_JSON" = "PUBLIC_JSON", metadata?: QortalGetMetadata ) => { + const {auth, appInfo} = useGlobal() + const username = auth?.name + const appNameHashed = appInfo?.appNameHashed const hasFetched = useRef(false); const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const publish = usePublishStore().getPublish(metadata || null); const setPublish = usePublishStore().setPublish; - const [hasResource, setHasResource] = useState(null) + const getPublish = usePublishStore().getPublish; + + const [hasResource, setHasResource] = useState(null); const fetchRawData = useCallback(async (item: QortalGetMetadata) => { const url = `/arbitrary/${item?.service}/${item?.name}/${item?.identifier}?encoding=base64`; const res = await fetch(url); @@ -22,8 +33,41 @@ export const usePublish = ( return base64ToObject(data); }, []); + const getStorageKey = useCallback(() => { + if (!username || !appNameHashed) return null; + return `qortal_publish_${username}_${appNameHashed}`; + }, [username, appNameHashed]); + + useEffect(() => { + if (!username || !appNameHashed) return; + + const storageKey = getStorageKey(); + if (!storageKey) return; + + const storedData: StoredPublish[] = JSON.parse(localStorage.getItem(storageKey) || "[]"); + + if (Array.isArray(storedData) && storedData.length > 0) { + const now = Date.now(); + const validPublishes = storedData.filter((item) => now - item.timestamp < STORAGE_EXPIRY_DURATION); + + // ✅ Re-populate the Zustand store only with recent publishes + validPublishes.forEach((publishData) => { + setPublish(publishData.qortalMetadata, { + qortalMetadata: publishData.qortalMetadata, + data: publishData.data + }, Date.now() - publishData.timestamp); + }); + + // ✅ Re-store only valid (non-expired) publishes + localStorage.setItem(storageKey, JSON.stringify(validPublishes)); + } + }, [username, appNameHashed, getStorageKey, setPublish]); + const fetchPublish = useCallback( - async (metadataProp: QortalGetMetadata, returnTypeProp: "PUBLIC_JSON" = "PUBLIC_JSON") => { + async ( + metadataProp: QortalGetMetadata, + returnTypeProp: "PUBLIC_JSON" = "PUBLIC_JSON" + ) => { let resourceExists = null; let resource = null; let error = null; @@ -31,6 +75,31 @@ export const usePublish = ( if (metadata) { setIsLoading(true); } + const hasCache = getPublish(metadataProp) + + if(hasCache){ + if(hasCache?.qortalMetadata.size === 32){ + if(metadata){ + setHasResource(false) + setError(null) + + } + return { + resource: null, + error: null, + resourceExists: false + } + } + if(metadata){ + setHasResource(true) + setError(null) + } + return { + resource: hasCache, + error: null, + resourceExists: true + } + } const url = `/arbitrary/resources/search?mode=ALL&service=${metadataProp?.service}&limit=1&includemetadata=true&reverse=true&excludeblocked=true&name=${metadataProp?.name}&exactmatchnames=true&offset=0&identifier=${metadataProp?.identifier}`; const responseMetadata = await fetch(url, { method: "GET", @@ -38,14 +107,33 @@ export const usePublish = ( "Content-Type": "application/json", }, }); - if (!responseMetadata?.ok) return false; + if (!responseMetadata?.ok) { + if (metadata) { + setError("Invalid search params"); + } + return { + resourceExists, + resource, + error: "Invalid search params", + }; + } const resMetadata = await responseMetadata.json(); if (resMetadata?.length === 0) { resourceExists = false; - setHasResource(false) + if (metadata) { + setHasResource(false); + } + } else if (resMetadata[0]?.size === 32) { + resourceExists = false; + if (metadata) { + setHasResource(false); + } } else { - resourceExists = true - setHasResource(true) + resourceExists = true; + if (metadata) { + setHasResource(true); + } + const response = await retryTransaction( fetchRawData, [metadataProp], @@ -91,10 +179,86 @@ export const usePublish = ( } }, [metadata, returnType]); + + const deleteResource = useCallback(async (publish: QortalGetMetadata) => { + const res = await qortalRequest({ + action: "PUBLISH_QDN_RESOURCE", + service: publish.service, + identifier: publish.identifier, + base64: "RA==", + }); + + if (res?.signature) { + const storageKey = getStorageKey(); + if (storageKey) { + const existingPublishes = JSON.parse(localStorage.getItem(storageKey) || "[]"); + + // Remove any previous entries for the same identifier + const updatedPublishes = existingPublishes.filter( + (item: StoredPublish) => item.qortalMetadata.identifier !== publish.identifier && item.qortalMetadata.service !== publish.service && item.qortalMetadata.name !== publish.name + ); + + // Add the new one with timestamp + updatedPublishes.push({ qortalMetadata: { + ...publish, + created: Date.now(), + updated: Date.now(), + size: 32 + }, data: "RA==", timestamp: Date.now() }); + + // Save back to storage + localStorage.setItem(storageKey, JSON.stringify(updatedPublishes)); + } + setPublish(publish, null); + setError(null) + setIsLoading(false) + setHasResource(false) + return true; + } + }, [getStorageKey]); + + + const updatePublish = useCallback(async (publish: QortalGetMetadata, data: any) => { + setError(null) + setIsLoading(false) + setHasResource(true) + setPublish(publish, {qortalMetadata: { + ...publish, + created: Date.now(), + updated: Date.now(), + size: 100 + }, data}); + + const storageKey = getStorageKey(); + if (storageKey) { + const existingPublishes = JSON.parse(localStorage.getItem(storageKey) || "[]"); + + // Remove any previous entries for the same identifier + const updatedPublishes = existingPublishes.filter( + (item: StoredPublish) => item.qortalMetadata.identifier !== publish.identifier && item.qortalMetadata.service !== publish.service && item.qortalMetadata.name !== publish.name + ); + + // Add the new one with timestamp + updatedPublishes.push({ qortalMetadata: { + ...publish, + created: Date.now(), + updated: Date.now(), + size: 100 + }, data, timestamp: Date.now() }); + + // Save back to storage + localStorage.setItem(storageKey, JSON.stringify(updatedPublishes)); + } + + }, [getStorageKey, setPublish]); + if (!metadata) return { fetchPublish, + updatePublish, + deletePublish: deleteResource, }; + return { isLoading, error, @@ -102,5 +266,7 @@ export const usePublish = ( hasResource, refetch: fetchPublish, fetchPublish, + updatePublish, + deletePublish: deleteResource, }; }; diff --git a/src/hooks/useResources.tsx b/src/hooks/useResources.tsx index 5b07486..b526f9f 100644 --- a/src/hooks/useResources.tsx +++ b/src/hooks/useResources.tsx @@ -259,7 +259,7 @@ export const useResources = (retryAttempts: number = 2) => { }); }, []); - const deleteProduct = useCallback(async (resourcesToDelete: QortalMetadata[]) => { + const deleteResource = useCallback(async (resourcesToDelete: QortalMetadata[]) => { @@ -288,10 +288,9 @@ export const useResources = (retryAttempts: number = 2) => { return { fetchResources, - fetchIndividualPublishJson, addNewResources, updateNewResources, - deleteProduct, + deleteResource, }; }; diff --git a/src/index.ts b/src/index.ts index 276ecef..4a49d97 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import './index.css' export { GlobalProvider, useGlobal } from "./context/GlobalProvider"; export {usePublish} from "./hooks/usePublish" -export {ResourceListDisplay} from "./components/ResourceList/ResourceListDisplay" \ No newline at end of file +export {ResourceListDisplay} from "./components/ResourceList/ResourceListDisplay" +export {QortalSearchParams} from './types/interfaces/resources' diff --git a/src/state/publishes.ts b/src/state/publishes.ts index 9ed2816..a5f4520 100644 --- a/src/state/publishes.ts +++ b/src/state/publishes.ts @@ -1,35 +1,65 @@ import { create } from "zustand"; -import { IdentifierBuilder } from "../utils/encryption"; import { QortalGetMetadata } from "../types/interfaces/resources"; import { TemporaryResource } from "../hooks/useResources"; - - -interface PublishState { - publishes: Record ; - - getPublish: (qortalGetMetadata: QortalGetMetadata | null) => TemporaryResource | null; - setPublish: (qortalGetMetadata: QortalGetMetadata, data: TemporaryResource) => void; +interface PublishCache { + data: TemporaryResource | null; + expiry: number; +} + +interface PublishState { + publishes: Record; + + getPublish: (qortalGetMetadata: QortalGetMetadata | null, ignoreExpire?: boolean) => TemporaryResource | null; + setPublish: (qortalGetMetadata: QortalGetMetadata, data: TemporaryResource | null, customExpiry?: number) => void; + clearExpiredPublishes: () => void; + publishExpiryDuration: number; // Default expiry duration } -// ✅ Typed Zustand Store export const usePublishStore = create((set, get) => ({ - publishes: {}, - getPublish: (qortalGetMetadata) => { - if(qortalGetMetadata === null) return null - const cache = get().publishes[`${qortalGetMetadata.service}-${qortalGetMetadata.name}-${qortalGetMetadata.identifier}`]; - if (cache) { - return cache - } - return null; - }, - setPublish: (qortalGetMetadata, data) => + publishes: {}, + publishExpiryDuration: 5 * 60 * 1000, // Default expiry: 5 minutes + + getPublish: (qortalGetMetadata, ignoreExpire = false) => { + if (!qortalGetMetadata) return null; + + const id = `${qortalGetMetadata.service}-${qortalGetMetadata.name}-${qortalGetMetadata.identifier}`; + const cache = get().publishes[id]; + + if (cache) { + if (cache.expiry > Date.now() || ignoreExpire) { + if(cache?.data?.qortalMetadata?.size === 32) return null + return cache.data; + } else { set((state) => { - return { - publishes: { - ...state.publishes, - [`${qortalGetMetadata.service}-${qortalGetMetadata.name}-${qortalGetMetadata.identifier}`]: data, - }, - }; - }), + const updatedPublishes = { ...state.publishes }; + delete updatedPublishes[id]; + return { publishes: updatedPublishes }; + }); + } + } + return null; + }, + + setPublish: (qortalGetMetadata, data, customExpiry) => { + const id = `${qortalGetMetadata.service}-${qortalGetMetadata.name}-${qortalGetMetadata.identifier}`; + const expiry = Date.now() + (customExpiry || get().publishExpiryDuration); + + set((state) => ({ + publishes: { + ...state.publishes, + [id]: { data, expiry }, + }, + })); + }, + + clearExpiredPublishes: () => { + set((state) => { + const now = Date.now(); + const updatedPublishes = Object.fromEntries( + Object.entries(state.publishes).filter(([_, cache]) => cache.expiry > now) + ); + return { publishes: updatedPublishes }; + }); + }, }));