This commit is contained in:
PhilReact 2025-03-25 23:50:04 +02:00
parent 9ca28b0645
commit 2898eeabf0
11 changed files with 256 additions and 12 deletions

40
package-lock.json generated
View File

@ -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",

View File

@ -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"

View File

@ -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 (
<div ref={elementRef} style={{
width: '100%',
height: '100%'
height: disableVirtualization ? 'auto' : '100%'
}}>
<ListLoader
noResultsMessage={
@ -284,7 +297,7 @@ export const MemorizedComponent = ({
<div
style={{
height: "100%",
height: disableVirtualization ? 'auto' : "100%",
display: "flex",
width: "100%",
}}

View File

@ -1,4 +1,4 @@
import React, { createContext, useContext, useMemo } from "react";
import React, { createContext, CSSProperties, useContext, useMemo } from "react";
import { useAuth, UseAuthProps } from "../hooks/useAuth";
import { useResources } from "../hooks/useResources";
import { useAppInfo } from "../hooks/useAppInfo";
@ -8,6 +8,7 @@ import { objectToBase64 } from "../utils/base64";
import { base64ToObject } from "../utils/publish";
import { generateBloomFilterBase64, isInsideBloom } from "../utils/bloomFilter";
import { formatTimestamp } from "../utils/time";
import { Toaster } from "react-hot-toast";
const utils = {
@ -41,6 +42,7 @@ interface GlobalProviderProps {
appName: string;
publicSalt: string
};
toastStyle: CSSProperties
}
// ✅ Create Context with Proper Type
@ -49,7 +51,7 @@ const GlobalContext = createContext<GlobalContextType | null>(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 (
<GlobalContext.Provider value={contextValue}>
<Toaster
position="top-center"
toastOptions={{
duration: 4000,
style: toastStyle
}}
containerStyle={{zIndex: 999999}}
/>
{children}
</GlobalContext.Provider>
);

5
src/global.d.ts vendored
View File

@ -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<any>

View File

@ -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);

View File

@ -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<string, AbortController>();
const getArbitraryResource = async (
@ -305,6 +307,7 @@ export const useResources = (retryAttempts: number = 2) => {
addNewResources,
updateNewResources,
deleteResource,
deleteList
};
};

View File

@ -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'
export {SymmetricKeys} from './utils/encryption'

View File

@ -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(

123
src/utils/text.ts Normal file
View File

@ -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(/<span[^>]*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<HTMLElement>) => {
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
})
}
}
};

31
src/utils/toast.tsx Normal file
View File

@ -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(
<div style={{ display: "flex", alignItems: "center" }}>
<span role="img" aria-label="loading-icon">
</span>
<span style={{ marginLeft: 8 }}>{message}</span>
</div>,
{
duration,
}
);
};
export const dismissToast = (toastId?: string) => {
toast.dismiss(toastId);
};