diff --git a/package-lock.json b/package-lock.json index 2c413c4..41f834b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,8 +19,10 @@ "buffer": "^6.0.3", "compressorjs": "^1.2.1", "dayjs": "^1.11.13", + "dompurify": "^3.2.4", "react": "^19.0.0", "react-dropzone": "^14.3.8", + "react-hot-toast": "^2.5.2", "react-intersection-observer": "^9.16.0", "short-unique-id": "^5.2.0", "zustand": "^4.3.2" @@ -1304,6 +1306,12 @@ "resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-3.0.8.tgz", "integrity": "sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, "node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", @@ -1626,6 +1634,14 @@ "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", + "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -1793,6 +1809,14 @@ "node": ">=4" } }, + "node_modules/goober": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2278,6 +2302,22 @@ "react": ">= 16.8 || 18.0.0" } }, + "node_modules/react-hot-toast": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz", + "integrity": "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-intersection-observer": { "version": "9.16.0", "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz", diff --git a/package.json b/package.json index 73937ea..2a41efc 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,10 @@ "buffer": "^6.0.3", "compressorjs": "^1.2.1", "dayjs": "^1.11.13", + "dompurify": "^3.2.4", "react": "^19.0.0", "react-dropzone": "^14.3.8", + "react-hot-toast": "^2.5.2", "react-intersection-observer": "^9.16.0", "short-unique-id": "^5.2.0", "zustand": "^4.3.2" diff --git a/src/components/ResourceList/ResourceListDisplay.tsx b/src/components/ResourceList/ResourceListDisplay.tsx index 55a20a0..dee49e0 100644 --- a/src/components/ResourceList/ResourceListDisplay.tsx +++ b/src/components/ResourceList/ResourceListDisplay.tsx @@ -52,6 +52,10 @@ export interface DefaultLoaderParams { export type ReturnType = 'JSON' | 'BASE64' +export interface Results { + resourceItems: QortalMetadata[] + isLoadingList: boolean +} interface BaseProps { search: QortalSearchParams; entityParams?: EntityParams; @@ -68,8 +72,9 @@ interface BaseProps { resourceCacheDuration?: number disablePagination?: boolean disableScrollTracker?: boolean - retryAttempts: number, + retryAttempts: number returnType: 'JSON' | 'BASE64' + onResults?: (results: Results)=> void } // ✅ Restrict `direction` only when `disableVirtualization = false` @@ -105,7 +110,8 @@ export const MemorizedComponent = ({ disableScrollTracker, entityParams, returnType = 'JSON', - retryAttempts = 2 + retryAttempts = 2, + onResults }: PropsResourceListDisplay) => { const { filterOutDeletedResources } = useCacheStore(); const {identifierOperations, lists} = useGlobal() @@ -210,7 +216,14 @@ export const MemorizedComponent = ({ return filterOutDeletedResources([...temporaryResources, ...list]) }, [list, listName, deletedResources, temporaryResources]) - + useEffect(()=> { + if(onResults){ + onResults({ + resourceItems: listToDisplay, + isLoadingList: isLoading + }) + } + }, [listToDisplay, onResults, isLoading]) const getResourceMoreList = useCallback(async (displayLimit?: number) => { @@ -267,7 +280,7 @@ export const MemorizedComponent = ({ return (
(null); // 🔹 Global Provider (Handles Multiple Hooks) -export const GlobalProvider = ({ children, config }: GlobalProviderProps) => { +export const GlobalProvider = ({ children, config, toastStyle = {} }: GlobalProviderProps) => { // ✅ Call hooks and pass in options dynamically const auth = useAuth(config?.auth || {}); const appInfo = useAppInfo(config.appName, config?.publicSalt) @@ -60,6 +62,14 @@ export const GlobalProvider = ({ children, config }: GlobalProviderProps) => { const contextValue = useMemo(() => ({ auth, lists, appInfo, identifierOperations, utils }), [auth, lists, appInfo, identifierOperations]); return ( + {children} ); diff --git a/src/global.d.ts b/src/global.d.ts index 959ffb0..0d1c920 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -36,13 +36,14 @@ interface QortalRequestOptions { prefix?: boolean exactMatchNames?: boolean base64?: string - groupId?: number + groupId?: number | string isAdmins?: boolean payments?: any[] assetId?: number, - publicKeys?: string[], + publicKeys?: string[] recipient?: string, before?: number | null + qortalLink?: string } declare function qortalRequest(options: QortalRequestOptions): Promise diff --git a/src/hooks/useNameSearch.tsx b/src/hooks/useNameSearch.tsx index a00919b..6f40b55 100644 --- a/src/hooks/useNameSearch.tsx +++ b/src/hooks/useNameSearch.tsx @@ -15,7 +15,7 @@ export const useNameSearch = (value: string, limit = 20) => { setNameList([]) return } - setIsLoading(true); + const res = await fetch( `/names/search?query=${name}&prefix=true&limit=${listLimit}` ); @@ -36,6 +36,7 @@ export const useNameSearch = (value: string, limit = 20) => { ); // Debounce logic useEffect(() => { + setIsLoading(true); const handler = setTimeout(() => { checkIfNameExisits(value, limit); }, 500); diff --git a/src/hooks/useResources.tsx b/src/hooks/useResources.tsx index c2c75d1..4163eea 100644 --- a/src/hooks/useResources.tsx +++ b/src/hooks/useResources.tsx @@ -8,6 +8,7 @@ import { RequestQueueWithPromise } from "../utils/queue"; import { base64ToUint8Array, uint8ArrayToObject } from "../utils/base64"; import { retryTransaction } from "../utils/publish"; import { ReturnType } from "../components/ResourceList/ResourceListDisplay"; +import { useListStore } from "../state/lists"; export const requestQueueProductPublishes = new RequestQueueWithPromise(20); export const requestQueueProductPublishesBackup = new RequestQueueWithPromise( @@ -25,8 +26,9 @@ export const useResources = (retryAttempts: number = 2) => { getResourceCache, setResourceCache, addTemporaryResource, - markResourceAsDeleted, + markResourceAsDeleted } = useCacheStore(); + const deleteList = useListStore(state => state.deleteList) const requestControllers = new Map(); const getArbitraryResource = async ( @@ -305,6 +307,7 @@ export const useResources = (retryAttempts: number = 2) => { addNewResources, updateNewResources, deleteResource, + deleteList }; }; diff --git a/src/index.ts b/src/index.ts index c6921be..2dd62f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ import './index.css' +export { showLoading, dismissToast, showError, showSuccess } from './utils/toast'; +export { processText, sanitizedContent, extractComponents, handleClickText} from './utils/text'; export { RequestQueueWithPromise } from './utils/queue'; export { GlobalProvider, useGlobal } from "./context/GlobalProvider"; export {usePublish} from "./hooks/usePublish" @@ -9,4 +11,4 @@ export {useNameSearch} from './hooks/useNameSearch' export {Resource} from './hooks/useResources' export {Service} from './types/interfaces/resources' export {ListItem} from './state/cache' -export {SymmetricKeys} from './utils/encryption' \ No newline at end of file +export {SymmetricKeys} from './utils/encryption' diff --git a/src/utils/encryption.ts b/src/utils/encryption.ts index 29f0ca8..1b8929e 100644 --- a/src/utils/encryption.ts +++ b/src/utils/encryption.ts @@ -150,11 +150,29 @@ const getPublicKeysByNames = async (names: string[]) => { export const addAndEncryptSymmetricKeys = async ({ previousData, names, + disableAddNewKey }: { previousData: Object; names: string[]; + disableAddNewKey?: boolean }) => { try { + if(disableAddNewKey){ + + const groupmemberPublicKeys = await getPublicKeysByNames(names); + const symmetricKeyAndNonceBase64 = await objectToBase64(previousData); + const encryptedData = await qortalRequest({ + action: "ENCRYPT_DATA", + base64: symmetricKeyAndNonceBase64, + publicKeys: groupmemberPublicKeys, + }); + + if (encryptedData) { + return {encryptedData, publicKeys: groupmemberPublicKeys, symmetricKeys: previousData}; + } else { + throw new Error("Cannot encrypt content"); + } + } let highestKey = 0; if (previousData && Object.keys(previousData)?.length > 0) { highestKey = Math.max( diff --git a/src/utils/text.ts b/src/utils/text.ts new file mode 100644 index 0000000..1b0a5ff --- /dev/null +++ b/src/utils/text.ts @@ -0,0 +1,123 @@ +import DOMPurify from 'dompurify'; +import React from 'react' +export function processText(input: string): string { + const linkRegex = /(qortal:\/\/\S+)/g; + + function processNode(node: Node): void { + if (node.nodeType === Node.TEXT_NODE) { + const textContent = node.textContent ?? ''; + const parts = textContent.split(linkRegex); + + if (parts.length > 0) { + const fragment = document.createDocumentFragment(); + + parts.forEach((part) => { + if (part.startsWith('qortal://')) { + const link = document.createElement('span'); + link.setAttribute('data-url', part); + link.textContent = part; + link.style.color = 'var(--code-block-text-color)'; + link.style.textDecoration = 'underline'; + link.style.cursor = 'pointer'; + fragment.appendChild(link); + } else { + fragment.appendChild(document.createTextNode(part)); + } + }); + + // ✅ Ensure node is a ChildNode before calling replaceWith + const parent = node.parentNode; + if (parent) { + parent.replaceChild(fragment, node); + } + } + } else { + Array.from(node.childNodes).forEach(processNode); + } + } + + const wrapper = document.createElement('div'); + wrapper.innerHTML = input; + processNode(wrapper); + + return wrapper.innerHTML; + } + + + export const sanitizedContent = (htmlContent: string)=> { + return DOMPurify.sanitize(htmlContent, { + ALLOWED_TAGS: [ + 'a', 'b', 'i', 'em', 'strong', 'p', 'br', 'div', 'span', 'img', + 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 's', 'hr' + ], + ALLOWED_ATTR: [ + 'href', 'target', 'rel', 'class', 'src', 'alt', 'title', + 'width', 'height', 'style', 'align', 'valign', 'colspan', 'rowspan', 'border', 'cellpadding', 'cellspacing', 'data-url' + ], + }).replace(/]*data-url="qortal:\/\/use-embed\/[^"]*"[^>]*>.*?<\/span>/g, ''); + }; + + export const extractComponents = (url: string) => { + if (!url || !url.startsWith("qortal://")) { + return null; + } + + // Skip links starting with "qortal://use-" + if (url.startsWith("qortal://use-")) { + return null; + } + + url = url.replace(/^(qortal\:\/\/)/, ""); + if (url.includes("/")) { + let parts = url.split("/"); + const service = parts[0].toUpperCase(); + parts.shift(); + const name = parts[0]; + parts.shift(); + let identifier; + const path = parts.join("/"); + return { service, name, identifier, path }; + } + + return null; + }; + + export const handleClickText = async (e: React.MouseEvent) => { + e.preventDefault(); + const target = e.target as HTMLElement; + if (target.getAttribute('data-url')) { + const url = target.getAttribute('data-url'); + if(!url) return + let copyUrl: string = url + + try { + copyUrl = copyUrl?.replace(/^(qortal:\/\/)/, '') + if (copyUrl && copyUrl?.startsWith('use-')) { + // Handle the new 'use' format + const parts = copyUrl.split('/') + parts.shift() + const action = parts.length > 0 ? parts[0].split('-')[1] : null // e.g., 'invite' from 'action-invite' + parts.shift() + const id = parts.length > 0 ? parts[0].split('-')[1] : null // e.g., '321' from 'groupid-321' + if(action === 'join' && id){ + e.stopPropagation() + qortalRequest({ + action: 'JOIN_GROUP', + groupId: id + }) + return + } + } + } catch (error) { + //error + } + const res = extractComponents(url); + if (res) { + e.stopPropagation() + qortalRequest({ + action: 'OPEN_NEW_TAB', + qortalLink: url + }) + } + } + }; \ No newline at end of file diff --git a/src/utils/toast.tsx b/src/utils/toast.tsx new file mode 100644 index 0000000..823dcad --- /dev/null +++ b/src/utils/toast.tsx @@ -0,0 +1,31 @@ +import { toast } from "react-hot-toast"; +import React from "react"; +export const showSuccess = (message: string, duration = 4000) => { + toast.success(message, { + duration, + }); +}; + +export const showError = (message: string, duration = 4000) => { + toast.error(message, { + duration, + }); +}; + +export const showLoading = (message: string, duration = Infinity): string => { + return toast.loading( +
+ + ⏳ + + {message} +
, + { + duration, + } + ); +}; + +export const dismissToast = (toastId?: string) => { + toast.dismiss(toastId); +};