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);
+};