From 52f1d570a667aea5da465c4105d7a7c74db9d1cf Mon Sep 17 00:00:00 2001 From: PhilReact Date: Sat, 15 Mar 2025 15:16:32 +0200 Subject: [PATCH] added list funcitons and keep scroll height before expiry --- package-lock.json | 13 +- package.json | 4 +- src/common/VirtualizedList.tsx | 6 +- src/common/useScrollTracker.tsx | 26 ++ src/common/useScrollTrackerRef.tsx | 30 +++ src/components/ResourceList/DynamicGrid.tsx | 5 +- .../ResourceList/HorizontalPaginationList.tsx | 113 ++++++++ .../ResourceList/ResourceListDisplay.tsx | 84 ++++-- src/global.d.ts | 3 +- src/hooks/useMergeRefs.tsx | 18 ++ src/hooks/useResources.tsx | 70 +++-- src/state/cache.ts | 251 +++++++++++------- src/state/lists.ts | 1 + 13 files changed, 480 insertions(+), 144 deletions(-) create mode 100644 src/common/useScrollTracker.tsx create mode 100644 src/common/useScrollTrackerRef.tsx create mode 100644 src/components/ResourceList/HorizontalPaginationList.tsx create mode 100644 src/hooks/useMergeRefs.tsx diff --git a/package-lock.json b/package-lock.json index 51b2e40..77f49b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "qapp-core", - "version": "1.0.0", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "qapp-core", - "version": "1.0.0", + "version": "1.0.3", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", @@ -14,12 +14,12 @@ "@mui/icons-material": "^6.4.7", "@mui/material": "^6.4.7", "@tanstack/react-virtual": "^3.13.2", + "@types/react": "^19.0.10", "react": "^19.0.0", "react-intersection-observer": "^9.16.0", "zustand": "^4.3.2" }, "devDependencies": { - "@types/react": "^18.0.27", "tsup": "^7.0.0", "typescript": "^5.2.0" } @@ -1012,11 +1012,10 @@ "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" }, "node_modules/@types/react": { - "version": "18.3.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", - "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", + "version": "19.0.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", + "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", "dependencies": { - "@types/prop-types": "*", "csstype": "^3.0.2" } }, diff --git a/package.json b/package.json index e40ea7d..04463d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qapp-core", - "version": "1.0.2", + "version": "1.0.3", "description": "Qortal's core React library with global state, UI components, and utilities", "main": "dist/index.js", "module": "dist/index.mjs", @@ -25,12 +25,12 @@ "@mui/icons-material": "^6.4.7", "@mui/material": "^6.4.7", "@tanstack/react-virtual": "^3.13.2", + "@types/react": "^19.0.10", "react": "^19.0.0", "react-intersection-observer": "^9.16.0", "zustand": "^4.3.2" }, "devDependencies": { - "@types/react": "^18.0.27", "tsup": "^7.0.0", "typescript": "^5.2.0" }, diff --git a/src/common/VirtualizedList.tsx b/src/common/VirtualizedList.tsx index e691ba5..c038a3c 100644 --- a/src/common/VirtualizedList.tsx +++ b/src/common/VirtualizedList.tsx @@ -8,14 +8,18 @@ import React, { import { useVirtualizer } from "@tanstack/react-virtual"; import { useInView } from "react-intersection-observer"; import { QortalMetadata } from "../types/interfaces/resources"; +import { useScrollTrackerRef } from "./useScrollTrackerRef"; +import { useMergeRefs } from "../hooks/useMergeRefs"; interface PropsVirtualizedList { list: any[]; children: (item: any, index: number) => React.ReactNode; onSeenLastItem?: (item: QortalMetadata)=> void; + listName: string } -export const VirtualizedList = ({ list, children, onSeenLastItem }: PropsVirtualizedList) => { +export const VirtualizedList = ({ list, children, onSeenLastItem, listName }: PropsVirtualizedList) => { const parentRef = useRef(null); +useScrollTrackerRef(listName, parentRef) const rowVirtualizer = useVirtualizer({ count: list.length, diff --git a/src/common/useScrollTracker.tsx b/src/common/useScrollTracker.tsx new file mode 100644 index 0000000..0f7c4d2 --- /dev/null +++ b/src/common/useScrollTracker.tsx @@ -0,0 +1,26 @@ +import { useEffect, useRef } from "react"; + +export const useScrollTracker = (listName: string) => { + useEffect(() => { + if (!listName) return; + + const SCROLL_KEY = `scroll-position-${listName}`; + + // 🔹 Restore saved scroll position for the given list + const savedPosition = sessionStorage.getItem(SCROLL_KEY); + if (savedPosition) { + window.scrollTo(0, parseInt(savedPosition, 10)); + } + + const handleScroll = () => { + sessionStorage.setItem(SCROLL_KEY, window.scrollY.toString()); + }; + + // 🔹 Save scroll position on scroll + window.addEventListener("scroll", handleScroll); + + return () => { + window.removeEventListener("scroll", handleScroll); + }; + }, [listName]); // ✅ Only runs when listName changes +}; \ No newline at end of file diff --git a/src/common/useScrollTrackerRef.tsx b/src/common/useScrollTrackerRef.tsx new file mode 100644 index 0000000..118c13a --- /dev/null +++ b/src/common/useScrollTrackerRef.tsx @@ -0,0 +1,30 @@ +import { useEffect } from "react"; + +export const useScrollTrackerRef = (listName: string, ref: React.RefObject) => { + useEffect(() => { + if (!listName || !ref.current) return; + + const SCROLL_KEY = `scroll-position-${listName}`; + const savedPosition = sessionStorage.getItem(SCROLL_KEY); + + + if (savedPosition && ref.current) { + ref.current.scrollTop = parseInt(savedPosition, 10); + } + + + const handleScroll = () => { + if (ref.current) { + sessionStorage.setItem(SCROLL_KEY, ref.current.scrollTop.toString()); + } + }; + + ref.current.addEventListener("scroll", handleScroll); + + return () => { + if (ref.current) { + ref.current.removeEventListener("scroll", handleScroll); + } + }; + }, [listName, ref]); +}; \ No newline at end of file diff --git a/src/components/ResourceList/DynamicGrid.tsx b/src/components/ResourceList/DynamicGrid.tsx index 026715e..7883292 100644 --- a/src/components/ResourceList/DynamicGrid.tsx +++ b/src/components/ResourceList/DynamicGrid.tsx @@ -13,10 +13,11 @@ const DynamicGrid: React.FC = ({ minItemWidth = 200, // Minimum width per item gap = 10, // Space between items children, -}) => { + +}) => { return ( -
+
React.ReactNode; + loaderItem?: (status: "LOADING" | "ERROR") => React.ReactNode; + onLoadMore: () => void; + maxItems?: number; + minItemWidth?: number; + gap?: number; + isLoading?: boolean; + onSeenLastItem?: (listItem: ListItem) => void; +} + +export const HorizontalPaginatedList = ({ + items, + listItem, + loaderItem, + onLoadMore, + maxItems = 60, + minItemWidth, + gap, + isLoading, + onSeenLastItem, +}: HorizontalPaginatedListProps) => { + const listRef = useRef(null); + const [displayedItems, setDisplayedItems] = useState(items); + + useEffect(() => { + setDisplayedItems(items); + }, [items]); + + const preserveScroll = useCallback((updateFunction: () => void) => { + const container = listRef.current; + if (!container) return; + + const previousScrollLeft = container.scrollLeft; + const previousScrollWidth = container.scrollWidth; + + updateFunction(); // Perform the update (fetch new data, remove old) + + requestAnimationFrame(() => { + const newScrollWidth = container.scrollWidth; + container.scrollLeft = previousScrollLeft - (previousScrollWidth - newScrollWidth); + }); + }, []); + + useEffect(() => { + if (displayedItems.length > maxItems) { + preserveScroll(() => { + const excess = displayedItems.length - maxItems; + setDisplayedItems((prev) => prev.slice(excess)); // Trim from the start + }); + } + }, [displayedItems, maxItems, preserveScroll]); + + useEffect(() => { + const container = listRef.current; + if (!container) return; + + const handleScroll = () => { + if ( + container.scrollLeft + container.clientWidth >= container.scrollWidth - 10 && + !isLoading + ) { + onLoadMore(); + } + }; + + container.addEventListener("scroll", handleScroll); + return () => container.removeEventListener("scroll", handleScroll); + }, [onLoadMore, isLoading]); + + return ( +
+ ( + + + + ))} + > + {!isLoading && displayedItems.length > 0 && ( + { + onLoadMore(); + if (onSeenLastItem) { + // onSeenLastItem(displayedItems[displayedItems.length - 1]); + } + }} + /> + )} + + + +
+ ); +}; \ No newline at end of file diff --git a/src/components/ResourceList/ResourceListDisplay.tsx b/src/components/ResourceList/ResourceListDisplay.tsx index 6126fff..64e86e5 100644 --- a/src/components/ResourceList/ResourceListDisplay.tsx +++ b/src/components/ResourceList/ResourceListDisplay.tsx @@ -2,8 +2,11 @@ import React, { CSSProperties, useCallback, useEffect, + useLayoutEffect, useMemo, + useRef, useState, + useTransition, } from "react"; import { QortalMetadata, @@ -19,6 +22,7 @@ import { Spacer } from "../../common/Spacer"; import DynamicGrid from "./DynamicGrid"; import LazyLoad from "../../common/LazyLoad"; import { useListStore } from "../../state/lists"; +import { useScrollTracker } from "../../common/useScrollTracker"; type Direction = "VERTICAL" | "HORIZONTAL"; interface ResourceListStyles { @@ -39,7 +43,7 @@ interface DefaultLoaderParams { listItemErrorText?: string; } -interface BaseProps { +interface BaseProps { params: QortalSearchParams; listItem: (item: ListItem, index: number) => React.ReactNode; styles?: ResourceListStyles; @@ -48,7 +52,8 @@ interface BaseProps { loaderList?: (status: "LOADING" | "NO_RESULTS") => React.ReactNode; disableVirtualization?: boolean; onSeenLastItem?: (listItem: QortalMetadata) => void; - listName: string + listName: string, + children?: React.ReactNode; } // ✅ Restrict `direction` only when `disableVirtualization = false` @@ -64,7 +69,7 @@ interface NonVirtualizedProps extends BaseProps { type PropsResourceListDisplay = VirtualizedProps | NonVirtualizedProps; -export const ResourceListDisplay = ({ +export const MemorizedComponent = ({ params, listItem, styles = { @@ -76,25 +81,40 @@ export const ResourceListDisplay = ({ disableVirtualization, direction = "VERTICAL", onSeenLastItem, - listName -}: PropsResourceListDisplay) => { + listName, +}: PropsResourceListDisplay) => { const { fetchResources } = useResources(); - const { getTemporaryResources } = useCacheStore(); + const { getTemporaryResources, filterOutDeletedResources } = useCacheStore(); const [isLoading, setIsLoading] = useState(false); const memoizedParams = useMemo(() => JSON.stringify(params), [params]); const addList = useListStore().addList const addItems = useListStore().addItems + const getListByName = useListStore().getListByName const list = useListStore().getListByName(listName) + const isListExpired = useCacheStore().isListExpired(listName) + const initialized = useRef(false) + useScrollTracker(listName); + const listToDisplay = useMemo(()=> { - return [...getTemporaryResources(listName), ...list] - }, [list, listName]) + return filterOutDeletedResources([...getTemporaryResources(listName), ...list]) + }, [list, listName, filterOutDeletedResources, getTemporaryResources]) + const getResourceList = useCallback(async () => { try { + await new Promise((res)=> { + setTimeout(() => { + res(null) + }, 500); + }) setIsLoading(true); const parsedParams = JSON.parse(memoizedParams); - const res = await fetchResources(parsedParams, listName, true); // Awaiting the async function - addList(listName, res || []) + const responseData = await fetchResources(parsedParams, listName, true); // Awaiting the async function + + + + addList(listName, responseData || []); + } catch (error) { console.error("Failed to fetch resources:", error); } finally { @@ -108,8 +128,8 @@ export const ResourceListDisplay = ({ const parsedParams = {...(JSON.parse(memoizedParams))}; parsedParams.before = list.length === 0 ? null : list[list.length - 1]?.created parsedParams.offset = null - const res = await fetchResources(parsedParams, listName); // Awaiting the async function - addItems(listName, res || []) + const responseData = await fetchResources(parsedParams, listName); // Awaiting the async function + addItems(listName, responseData || []) } catch (error) { console.error("Failed to fetch resources:", error); } finally { @@ -118,8 +138,12 @@ export const ResourceListDisplay = ({ }, [memoizedParams, listName, list]); useEffect(() => { + if(initialized.current) return + initialized.current = true + if(!isListExpired) return + sessionStorage.removeItem(`scroll-position-${listName}`); getResourceList(); - }, [getResourceList]); // Runs when dependencies change + }, [getResourceList, isListExpired]); // Runs when dependencies change const disabledVirutalizationStyles: CSSProperties = useMemo(() => { if (styles?.disabledVirutalizationStyles?.parentContainer) @@ -133,6 +157,15 @@ export const ResourceListDisplay = ({ }; }, [styles?.disabledVirutalizationStyles, styles?.gap, direction]); + useEffect(() => { + const clearOnReload = () => { + sessionStorage.removeItem(`scroll-position-${listName}`); + }; + + window.addEventListener("beforeunload", clearOnReload); + return () => window.removeEventListener("beforeunload", clearOnReload); + }, [listName]); + return (
{!disableVirtualization && ( - { + { getResourceMoreList() if(onSeenLastItem){ onSeenLastItem(item) @@ -179,6 +212,7 @@ export const ResourceListDisplay = ({ )} {disableVirtualization && direction === "HORIZONTAL" && ( <> + ); -}; +} + + +function arePropsEqual( + prevProps: PropsResourceListDisplay, + nextProps: PropsResourceListDisplay +): boolean { + return ( + prevProps.listName === nextProps.listName && + prevProps.disableVirtualization === nextProps.disableVirtualization && + prevProps.direction === nextProps.direction && + prevProps.onSeenLastItem === nextProps.onSeenLastItem && + JSON.stringify(prevProps.params) === JSON.stringify(nextProps.params) && + JSON.stringify(prevProps.styles) === JSON.stringify(nextProps.styles) + ); +} + +export const ResourceListDisplay = React.memo(MemorizedComponent, arePropsEqual); + interface ListItemWrapperProps { item: QortalMetadata; @@ -256,7 +308,7 @@ interface ListItemWrapperProps { renderListItemLoader?: (status: "LOADING" | "ERROR") => React.ReactNode; } -const ListItemWrapper: React.FC = ({ +export const ListItemWrapper: React.FC = ({ item, index, render, diff --git a/src/global.d.ts b/src/global.d.ts index addf55c..959ffb0 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -41,7 +41,8 @@ interface QortalRequestOptions { payments?: any[] assetId?: number, publicKeys?: string[], - recipient?: string + recipient?: string, + before?: number | null } declare function qortalRequest(options: QortalRequestOptions): Promise diff --git a/src/hooks/useMergeRefs.tsx b/src/hooks/useMergeRefs.tsx new file mode 100644 index 0000000..d79374f --- /dev/null +++ b/src/hooks/useMergeRefs.tsx @@ -0,0 +1,18 @@ +import { useEffect, useRef } from "react"; + +export function useMergeRefs(...refs: (React.Ref | undefined)[]) { + const mergedRef = useRef(null as unknown as T); // ✅ Ensures correct type + + useEffect(() => { + refs.forEach((ref) => { + if (!ref) return; + if (typeof ref === "function") { + ref(mergedRef.current); + } else if (ref && "current" in ref) { + (ref as React.MutableRefObject).current = mergedRef.current; + } + }); + }, [refs]); + + return mergedRef; +} diff --git a/src/hooks/useResources.tsx b/src/hooks/useResources.tsx index 2344e7a..4746a44 100644 --- a/src/hooks/useResources.tsx +++ b/src/hooks/useResources.tsx @@ -1,5 +1,6 @@ import React, { useCallback } from "react"; import { + QortalMetadata, QortalSearchParams, } from "../types/interfaces/resources"; @@ -23,7 +24,8 @@ export const useResources = () => { getSearchCache, getResourceCache, setResourceCache, - addTemporaryResource + addTemporaryResource, + markResourceAsDeleted } = useCacheStore(); const requestControllers = new Map(); @@ -151,35 +153,54 @@ export const useResources = () => { ): Promise => { if (cancelRequests) { cancelAllRequests(); - await new Promise((res) => { - setTimeout(() => { - res(null); - }, 250); - }); } + const cacheKey = generateCacheKey(params); const searchCache = getSearchCache(listName, cacheKey); - let responseData = []; - if (searchCache) { - responseData = searchCache; - } else { + return searchCache; + } + + let responseData: QortalMetadata[] = []; + let filteredResults: QortalMetadata[] = []; + let lastCreated = params.before || null; + const targetLimit = params.limit ?? 20; // Use `params.limit` if provided, else default to 20 + + while (filteredResults.length < targetLimit) { const response = await qortalRequest({ action: "SEARCH_QDN_RESOURCES", mode: "ALL", - limit: 20, ...params, + limit: targetLimit - filteredResults.length, // Adjust limit dynamically + before: lastCreated, }); - if (!response) throw new Error("Unable to fetch resources"); + + if (!response || response.length === 0) { + break; // No more data available + } + responseData = response; + const validResults = responseData.filter(item => item.size !== 32); + filteredResults = [...filteredResults, ...validResults]; + + if (filteredResults.length >= targetLimit) { + filteredResults = filteredResults.slice(0, targetLimit); + break; + } + + lastCreated = responseData[responseData.length - 1]?.created; + if (!lastCreated) break; } - setSearchCache(listName, cacheKey, responseData); - fetchDataFromResults(responseData); - - return responseData; + + setSearchCache(listName, cacheKey, filteredResults); + fetchDataFromResults(filteredResults); + + return filteredResults; }, [getSearchCache, setSearchCache, fetchDataFromResults] ); + + const addNewResources = useCallback( (listName: string, resources: TemporaryResource[]) => { @@ -209,11 +230,26 @@ export const useResources = () => { }, [] ); + + const deleteProduct = useCallback(async (qortalMetadata: QortalMetadata)=> { + if(!qortalMetadata?.service || !qortalMetadata?.identifier) throw new Error('Missing fields') + await qortalRequest({ + action: "PUBLISH_QDN_RESOURCE", + service: qortalMetadata.service, + identifier: qortalMetadata.identifier, + base64: 'RA==', + }); + markResourceAsDeleted(qortalMetadata) + return true + }, []) + + return { fetchResources, fetchIndividualPublish, addNewResources, - updateNewResources + updateNewResources, + deleteProduct }; }; diff --git a/src/state/cache.ts b/src/state/cache.ts index da9ee0f..2e631fb 100644 --- a/src/state/cache.ts +++ b/src/state/cache.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import { QortalMetadata } from "../types/interfaces/resources"; +import { persist } from "zustand/middleware"; interface SearchCache { @@ -43,6 +44,10 @@ interface resourceCache { }; } + interface DeletedResources { + [key: string]: { deleted: true; expiry: number }; // ✅ Added expiry field + } + interface CacheState { resourceCache: resourceCache; @@ -56,107 +61,157 @@ interface CacheState { getResourceCache: (id: string, ignoreExpire?: boolean) => ListItem | false | null; addTemporaryResource: (listName: string, newResources: QortalMetadata[], customExpiry?: number)=> void; getTemporaryResources:(listName: string)=> QortalMetadata[] + deletedResources: DeletedResources; + markResourceAsDeleted: (item: QortalMetadata) => void; + filterOutDeletedResources: (items: QortalMetadata[]) => QortalMetadata[]; + isListExpired: (listName: string)=> boolean } -export const useCacheStore = create((set, get) => ({ - resourceCache: {}, - searchCache: {}, - orderCache: {}, - messageCache: {}, +export const useCacheStore = create + ((set, get) => ({ + resourceCache: {}, + searchCache: {}, + deletedResources: {}, - 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: (listName, searchTerm, data, customExpiry) => - set((state) => { - const expiry = Date.now() + (customExpiry || 5 * 60 * 1000); // 5mins from now - - return { - searchCache: { - ...state.searchCache, - [listName]: { - searches: { - ...(state.searchCache[listName]?.searches || {}), // Preserve existing searches - [searchTerm]: data, // Store new search term results - }, - temporaryNewResources: state.searchCache[listName]?.temporaryNewResources || [], // Preserve existing temp resources - expiry, // Expiry for the entire list - }, - }, - }; - }), - - - // Retrieve cached search results - getSearchCache: (listName, searchTerm) => { - const cache = get().searchCache[listName]; - if (cache && cache.expiry > Date.now()) { - return cache.searches[searchTerm] || null; // Return specific search term results - } - return null; // Cache expired or doesn't exist - }, - addTemporaryResource: (listName, newResources, customExpiry) => - set((state) => { - const expiry = Date.now() + (customExpiry || 5 * 60 * 1000); // Reset expiry - - const existingResources = state.searchCache[listName]?.temporaryNewResources || []; - - // Merge and remove duplicates, keeping the latest by `created` timestamp - const uniqueResourcesMap = new Map(); - - [...existingResources, ...newResources].forEach((item) => { - const key = `${item.service}-${item.name}-${item.identifier}`; - const existingItem = uniqueResourcesMap.get(key); - - if (!existingItem || item.created > existingItem.created) { - uniqueResourcesMap.set(key, item); + getResourceCache: (id, ignoreExpire) => { + const cache = get().resourceCache[id]; + if (cache) { + if (cache.expiry > Date.now() || ignoreExpire) { + return cache.data; // ✅ Return data if not expired + } else { + set((state) => { + const updatedCache = { ...state.resourceCache }; + delete updatedCache[id]; // ✅ Remove expired entry + return { resourceCache: updatedCache }; + }); + } } - }); - - return { - searchCache: { - ...state.searchCache, - [listName]: { - ...state.searchCache[listName], - temporaryNewResources: Array.from(uniqueResourcesMap.values()), // Store unique items - expiry, // Reset expiry - }, + return null; + }, + + setResourceCache: (id, data, customExpiry) => + set((state) => { + const expiry = Date.now() + (customExpiry || 30 * 60 * 1000); // 30 mins + return { + resourceCache: { + ...state.resourceCache, + [id]: { data, expiry }, + }, + }; + }), + + setSearchCache: (listName, searchTerm, data, customExpiry) => + set((state) => { + const expiry = Date.now() + (customExpiry || 5 * 60 * 1000); // 5 mins + + return { + searchCache: { + ...state.searchCache, + [listName]: { + searches: { + ...(state.searchCache[listName]?.searches || {}), + [searchTerm]: data, + }, + temporaryNewResources: state.searchCache[listName]?.temporaryNewResources || [], + expiry, + }, + }, + }; + }), + + getSearchCache: (listName, searchTerm) => { + const cache = get().searchCache[listName]; + if (cache) { + if (cache.expiry > Date.now()) { + return cache.searches[searchTerm] || null; // ✅ Return if valid + } else { + set((state) => { + const updatedCache = { ...state.searchCache }; + delete updatedCache[listName]; // ✅ Remove expired list + return { searchCache: updatedCache }; + }); + } + } + return null; }, - }; + + addTemporaryResource: (listName, newResources, customExpiry) => + set((state) => { + const expiry = Date.now() + (customExpiry || 5 * 60 * 1000); + + const existingResources = state.searchCache[listName]?.temporaryNewResources || []; + + // Merge & remove duplicates, keeping the latest by `created` timestamp + const uniqueResourcesMap = new Map(); + + [...existingResources, ...newResources].forEach((item) => { + const key = `${item.service}-${item.name}-${item.identifier}`; + const existingItem = uniqueResourcesMap.get(key); + + if (!existingItem || item.created > existingItem.created) { + uniqueResourcesMap.set(key, item); + } + }); + + return { + searchCache: { + ...state.searchCache, + [listName]: { + ...state.searchCache[listName], + temporaryNewResources: Array.from(uniqueResourcesMap.values()), + expiry, + }, + }, + }; + }), + + getTemporaryResources: (listName: string) => { + const cache = get().searchCache[listName]; + if (cache && cache.expiry > Date.now()) { + return cache.temporaryNewResources || []; + } + return []; + }, + + markResourceAsDeleted: (item) => + set((state) => { + const now = Date.now(); + const expiry = now + 5 * 60 * 1000; // ✅ Expires in 5 minutes + + // ✅ Remove expired deletions before adding a new one + const validDeletedResources = Object.fromEntries( + Object.entries(state.deletedResources).filter(([_, value]) => value.expiry > now) + ); + + const key = `${item.service}-${item.name}-${item.identifier}`; + return { + deletedResources: { + ...validDeletedResources, // ✅ Keep only non-expired ones + [key]: { deleted: true, expiry }, + }, + }; + }), + + filterOutDeletedResources: (items) => { + const deletedResources = get().deletedResources; // ✅ Read without modifying store + return items.filter( + (item) => !deletedResources[`${item.service}-${item.name}-${item.identifier}`] + ); + }, + isListExpired: (listName: string): boolean => { + const cache = get().searchCache[listName]; + return cache ? cache.expiry <= Date.now() : true; // ✅ Expired if expiry timestamp is in the past + }, + + + clearExpiredCache: () => + set((state) => { + const now = Date.now(); + const validSearchCache = Object.fromEntries( + Object.entries(state.searchCache).filter(([, value]) => value.expiry > now) + ); + return { searchCache: validSearchCache }; + }), }), - getTemporaryResources: (listName: string) => { - const cache = get().searchCache[listName]; - if (cache && cache.expiry > Date.now()) { - return cache.temporaryNewResources || []; - } - return []; // Return empty array if expired or doesn't exist - }, - // Clear expired caches - clearExpiredCache: () => - set((state) => { - const now = Date.now(); - const validSearchCache = Object.fromEntries( - Object.entries(state.searchCache).filter( - ([, value]) => value.expiry > now // Only keep unexpired lists - ) - ); - return { - searchCache: validSearchCache, - }; - }), -})); + +); \ No newline at end of file diff --git a/src/state/lists.ts b/src/state/lists.ts index 6ac58df..254076f 100644 --- a/src/state/lists.ts +++ b/src/state/lists.ts @@ -1,5 +1,6 @@ import {create} from "zustand"; import { QortalMetadata } from "../types/interfaces/resources"; +import { persist } from "zustand/middleware"; interface ListsState {