diff --git a/package-lock.json b/package-lock.json index a156375..a03e759 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,7 @@ "react-singleton-hook": "^4.0.1", "react-toastify": "10.0.4", "redux": "5.0.1", - "svgo": "^3.3.2", + "signature_pad": "^5.0.4", "tseep": "1.2.1" }, "devDependencies": { @@ -2214,6 +2214,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true, "license": "ISC", "engines": { "node": ">=10.13.0" @@ -2935,6 +2936,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, "license": "ISC" }, "node_modules/brace-expansion": { @@ -3634,6 +3636,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", @@ -3650,6 +3653,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, "license": "MIT", "dependencies": { "mdn-data": "2.0.30", @@ -3663,6 +3667,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">= 6" @@ -3675,6 +3680,7 @@ "version": "5.0.5", "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "dev": true, "license": "MIT", "dependencies": { "css-tree": "~2.2.0" @@ -3688,6 +3694,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "dev": true, "license": "MIT", "dependencies": { "mdn-data": "2.0.28", @@ -3702,6 +3709,7 @@ "version": "2.0.28", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "dev": true, "license": "CC0-1.0" }, "node_modules/csstype": { @@ -3949,6 +3957,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", @@ -3976,6 +3985,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, "funding": [ { "type": "github", @@ -3988,6 +3998,7 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" @@ -4003,6 +4014,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", @@ -4020,9 +4032,9 @@ "dev": true }, "node_modules/elliptic": { - "version": "6.5.7", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.7.tgz", - "integrity": "sha512-ESVCtTwiA+XhY3wyh24QqRGBoP3rEdDUl3EDUUo9tft074fi19IrdpH7hLCMMP3CIj7jb3W96rn8lt/BqIlt5Q==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.0.tgz", + "integrity": "sha512-dpwoQcLc/2WLQvJvLRHKZ+f9FgOdjnq11rurqwekGQygGPsYSK29OMMD2WalatiqQ+XGFDglTNixpPfI+lpaAA==", "dev": true, "license": "MIT", "dependencies": { @@ -4052,6 +4064,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -5979,6 +5992,7 @@ "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "dev": true, "license": "CC0-1.0" }, "node_modules/merge-stream": { @@ -6560,6 +6574,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0" @@ -6919,6 +6934,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -7866,6 +7882,12 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "optional": true }, + "node_modules/signature_pad": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-5.0.4.tgz", + "integrity": "sha512-nngOixbwLAUOuH3QnZwlgwmynQblxmo4iWacKFwfymJfiY+Qt+9icNtcIe/okqXKun4hJ5QTFmHyC7dmv6lf2w==", + "license": "MIT" + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -7971,6 +7993,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -8197,6 +8220,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", + "dev": true, "license": "MIT", "dependencies": { "@trysound/sax": "0.2.0", @@ -8222,6 +8246,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 10" diff --git a/package.json b/package.json index 3eaa670..ab49da0 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "react-singleton-hook": "^4.0.1", "react-toastify": "10.0.4", "redux": "5.0.1", - "svgo": "^3.3.2", + "signature_pad": "^5.0.4", "tseep": "1.2.1" }, "devDependencies": { diff --git a/src/App.tsx b/src/App.tsx index f25b23f..9f58f21 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,14 +3,13 @@ import { useAppSelector } from './hooks' import { Navigate, Route, Routes } from 'react-router-dom' import { AuthController } from './controllers' import { MainLayout } from './layouts/Main' +import { appPrivateRoutes, appPublicRoutes } from './routes' +import './App.scss' import { - appPrivateRoutes, - appPublicRoutes, privateRoutes, publicRoutes, recursiveRouteRenderer -} from './routes' -import './App.scss' +} from './routes/util' const App = () => { const authState = useAppSelector((state) => state.auth) diff --git a/src/assets/images/nostr-logo.png b/src/assets/images/nostr-logo.png new file mode 100644 index 0000000..ea73239 Binary files /dev/null and b/src/assets/images/nostr-logo.png differ diff --git a/src/components/MarkFormField/index.tsx b/src/components/MarkFormField/index.tsx index 7328065..18bbf31 100644 --- a/src/components/MarkFormField/index.tsx +++ b/src/components/MarkFormField/index.tsx @@ -7,7 +7,7 @@ import { isCurrentValueLast } from '../../utils' import React, { useState } from 'react' -import { MARK_TYPE_CONFIG } from '../getMarkComponents.tsx' +import { MarkInput } from '../MarkTypeStrategy/MarkInput.tsx' interface MarkFormFieldProps { currentUserMarks: CurrentUserMark[] @@ -52,8 +52,7 @@ const MarkFormField = ({ } const toggleActions = () => setDisplayActions(!displayActions) const markLabel = getToolboxLabelByMarkType(selectedMark.mark.type) - const { input: MarkInputComponent } = - MARK_TYPE_CONFIG[selectedMark.mark.type] || {} + return (
@@ -84,14 +83,14 @@ const MarkFormField = ({
handleFormSubmit(e)}> - {typeof MarkInputComponent !== 'undefined' && ( - - )} +
) })} diff --git a/src/components/getMarkComponents.tsx b/src/components/getMarkComponents.tsx deleted file mode 100644 index 507f388..0000000 --- a/src/components/getMarkComponents.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { MarkType } from '../types/drawing' -import { MarkConfigs } from '../types/mark' -import { MarkInputSignature } from './MarkInputs/Signature' -import { MarkInputText } from './MarkInputs/Text' -import { MarkRenderSignature } from './MarkRender/Signature' - -export const MARK_TYPE_CONFIG: MarkConfigs = { - [MarkType.TEXT]: { - input: MarkInputText, - render: ({ value }) => <>{value} - }, - [MarkType.SIGNATURE]: { - input: MarkInputSignature, - render: MarkRenderSignature - } -} diff --git a/src/hooks/useSigitMeta.tsx b/src/hooks/useSigitMeta.tsx index 8f79dc6..85841f2 100644 --- a/src/hooks/useSigitMeta.tsx +++ b/src/hooks/useSigitMeta.tsx @@ -21,6 +21,7 @@ import { Event } from 'nostr-tools' import store from '../store/store' import { NostrController } from '../controllers' import { MetaParseError } from '../types/errors/MetaParseError' +import { MARK_TYPE_CONFIG } from '../components/MarkTypeStrategy/MarkStrategy' /** * Flattened interface that combines properties `Meta`, `CreateSignatureEventContent`, @@ -142,6 +143,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { setMarkConfig(markConfig) setZipUrl(zipUrl) + let encryptionKey: string | null = null if (meta.keys) { const { sender, keys } = meta.keys // Retrieve the user's public key from the state @@ -162,6 +164,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { return null }) + encryptionKey = decrypted setEncryptionKey(decrypted) } } @@ -206,13 +209,40 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { } } - parsedSignatureEventsMap.forEach((event, npub) => { + for (const [npub, event] of parsedSignatureEventsMap) { const isValidSignature = verifyEvent(event) if (isValidSignature) { // get the signature of prev signer from the content of current signers signedEvent const prevSignersSig = getPrevSignerSig(npub) + try { const obj: SignedEventContent = JSON.parse(event.content) + + // Signature object can include values that need to be fetched and decrypted + for (let i = 0; i < obj.marks.length; i++) { + const m = obj.marks[i] + + try { + const { fetchAndDecrypt } = MARK_TYPE_CONFIG[m.type] || {} + if ( + typeof fetchAndDecrypt === 'function' && + m.value && + encryptionKey + ) { + const decrypted = await fetchAndDecrypt( + m.value, + encryptionKey + ) + obj.marks[i].value = decrypted + } + } catch (error) { + console.error( + `Error during mark fetchAndDecrypt phase`, + error + ) + } + } + parsedSignatureEventsMap.set(npub, { ...event, parsedContent: obj @@ -228,7 +258,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { signerStatusMap.set(npub as `npub1${string}`, SignStatus.Invalid) } } - }) + } signers .filter((s) => !parsedSignatureEventsMap.has(s)) diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index c57bda0..a65a559 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -1,10 +1,17 @@ import styles from './style.module.scss' -import { Button, FormHelperText, TextField, Tooltip } from '@mui/material' +import { + Box, + Button, + CircularProgress, + FormHelperText, + TextField, + Tooltip +} from '@mui/material' import type { Identifier, XYCoord } from 'dnd-core' import saveAs from 'file-saver' import JSZip from 'jszip' import { Event, kinds } from 'nostr-tools' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { DndProvider, useDrag, useDrop } from 'react-dnd' import { MultiBackend } from 'react-dnd-multi-backend' import { HTML5toTouch } from 'rdndmb-html5-to-touch' @@ -13,16 +20,20 @@ import { useLocation, useNavigate } from 'react-router-dom' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' import { UserAvatar } from '../../components/UserAvatar' -import { MetadataController, NostrController } from '../../controllers' +import { + MetadataController, + NostrController, + RelayController +} from '../../controllers' import { appPrivateRoutes } from '../../routes' import { CreateSignatureEventContent, + KeyboardCode, Meta, ProfileMetadata, SignedEvent, User, - UserRole, - KeyboardCode + UserRole } from '../../types' import { encryptArrayBuffer, @@ -58,13 +69,19 @@ import { faGripLines, faPen, faPlus, + faSearch, faToolbox, faTrash, faUpload } from '@fortawesome/free-solid-svg-icons' import { getSigitFile, SigitFile } from '../../utils/file.ts' -import _ from 'lodash' import { generateTimestamp } from '../../utils/opentimestamps.ts' +import { Autocomplete } from '@mui/lab' +import _, { truncate } from 'lodash' +import * as React from 'react' +import { AvatarIconButton } from '../../components/UserAvatarIconButton' + +type FoundUser = Event & { npub: string } export const CreatePage = () => { const navigate = useNavigate() @@ -87,23 +104,16 @@ export const CreatePage = () => { } const [userInput, setUserInput] = useState('') - const handleInputKeyDown = (event: React.KeyboardEvent) => { - if ( - event.code === KeyboardCode.Enter || - event.code === KeyboardCode.NumpadEnter - ) { - event.preventDefault() - handleAddUser() - } - } - const [userRole, setUserRole] = useState(UserRole.signer) + const [userSearchInput, setUserSearchInput] = useState('') + + const [userRole] = useState(UserRole.signer) const [error, setError] = useState() const [users, setUsers] = useState([]) const signers = users.filter((u) => u.role === UserRole.signer) const viewers = users.filter((u) => u.role === UserRole.viewer) - const usersPubkey = useAppSelector((state) => state.auth.usersPubkey) + const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)! const nostrController = NostrController.getInstance() @@ -112,10 +122,129 @@ export const CreatePage = () => { ) const [drawnFiles, setDrawnFiles] = useState([]) const [parsingPdf, setIsParsing] = useState(false) + + const searchFieldRef = useRef(null) + + const [selectedTool, setSelectedTool] = useState() + + const [foundUsers, setFoundUsers] = useState([]) + const [searchUsersLoading, setSearchUsersLoading] = useState(false) + const [pastedUserNpubOrNip05, setPastedUserNpubOrNip05] = useState< + string | undefined + >() + + /** + * Fired when user select + */ + const handleSearchUserChange = useCallback( + (_event: React.SyntheticEvent, value: string | FoundUser | null) => { + if (typeof value === 'object') { + const ndkEvent = value as FoundUser + if (ndkEvent?.pubkey) { + setUserInput(hexToNpub(ndkEvent.pubkey)) + } + } + }, + [setUserInput] + ) + + const handleSearchUsers = async (searchValue?: string) => { + const searchString = searchValue || userSearchInput || undefined + + if (!searchString) return + + setSearchUsersLoading(true) + + const relayController = RelayController.getInstance() + const metadataController = MetadataController.getInstance() + + const relaySet = await metadataController.findRelayListMetadata(usersPubkey) + const searchTerm = searchString.trim() + + relayController + .fetchEvents( + { + kinds: [0], + search: searchTerm + }, + [...relaySet.write] + ) + .then((events) => { + console.log('events', events) + + const fineFilteredEvents: FoundUser[] = events + .filter((event) => { + const lowercaseContent = event.content.toLowerCase() + + return ( + lowercaseContent.includes( + `"name":"${searchTerm.toLowerCase()}"` + ) || + lowercaseContent.includes( + `"display_name":"${searchTerm.toLowerCase()}"` + ) || + lowercaseContent.includes( + `"username":"${searchTerm.toLowerCase()}"` + ) || + lowercaseContent.includes(`"nip05":"${searchTerm.toLowerCase()}"`) + ) + }) + .reduce((uniqueEvents: FoundUser[], event: Event) => { + if (!uniqueEvents.some((e: Event) => e.pubkey === event.pubkey)) { + uniqueEvents.push({ + ...event, + npub: hexToNpub(event.pubkey) + }) + } + return uniqueEvents + }, []) + + console.log('fineFilteredEvents', fineFilteredEvents) + setFoundUsers(fineFilteredEvents) + }) + .catch((error) => { + console.error(error) + }) + .finally(() => { + setSearchUsersLoading(false) + }) + } + + useEffect(() => { + setTimeout(() => { + if (foundUsers.length) { + if (searchFieldRef.current) { + searchFieldRef.current.blur() + searchFieldRef.current.focus() + } + } + }) + }, [foundUsers]) + + const handleInputKeyDown = (event: React.KeyboardEvent) => { + if ( + event.code === KeyboardCode.Enter || + event.code === KeyboardCode.NumpadEnter + ) { + event.preventDefault() + + // If pasted user npub of nip05 is present, we just add the user to the counterparts list + if (pastedUserNpubOrNip05) { + setUserInput(pastedUserNpubOrNip05) + setPastedUserNpubOrNip05(undefined) + } else { + // Otherwize if search already provided some results, user must manually click the search button + if (!foundUsers.length) { + handleSearchUsers() + } + } + } + } + useEffect(() => { if (selectedFiles) { /** - * Reads the binary files and converts to internal file type + * Reads the binary files and converts to an internal file type * and sets to a state (adds images if it's a PDF) */ const parsePages = async () => { @@ -135,8 +264,6 @@ export const CreatePage = () => { } }, [selectedFiles]) - const [selectedTool, setSelectedTool] = useState() - /** * Changes the drawing tool * @param drawTool to draw with @@ -209,7 +336,7 @@ export const CreatePage = () => { } }, [usersPubkey]) - const handleAddUser = async () => { + const handleAddUser = useCallback(async () => { setError(undefined) const addUser = (pubkey: string) => { @@ -251,6 +378,8 @@ export const CreatePage = () => { const input = userInput.toLowerCase() + setUserSearchInput('') + if (input.startsWith('npub')) { return handleAddNpubUser(input) } @@ -300,7 +429,20 @@ export const CreatePage = () => { } return } - } + }, [ + userInput, + userRole, + setError, + setUsers, + setUserSearchInput, + setIsLoading, + setLoadingSpinnerDesc, + setUserInput + ]) + + useEffect(() => { + if (userInput?.length > 0) handleAddUser() + }, [handleAddUser, userInput]) const handleUserRoleChange = (role: UserRole, pubkey: string) => { setUsers((prevUsers) => @@ -789,6 +931,61 @@ export const CreatePage = () => { } } + /** + * Handles the user search textfield change + * If it's not valid npub or nip05, search will be automatically triggered + */ + const handleSearchAutocompleteTextfieldChange = async ( + e: React.ChangeEvent + ) => { + const value = e.target.value + + const disarmAddOnEnter = () => { + setPastedUserNpubOrNip05(undefined) + } + + // Seems like it's npub format + if (value.startsWith('npub')) { + // We will try to convert npub to hex and if it's successfull that means + // npub is valid + const validHexPubkey = npubToHex(value) + + if (validHexPubkey) { + // Arm the manual user npub add after enter is hit, we don't want to trigger search + setPastedUserNpubOrNip05(value) + } else { + disarmAddOnEnter() + } + } else if (value.includes('@')) { + // Seems like it's nip05 format + const { pubkey } = await queryNip05(value).catch((err) => { + console.error(err) + return { pubkey: null } + }) + + if (pubkey) { + // Arm the manual user npub add after enter is hit, we don't want to trigger search + setPastedUserNpubOrNip05(hexToNpub(pubkey)) + } else { + disarmAddOnEnter() + } + } else { + // Disarm the add user on enter hit, and trigger search after 1 second + disarmAddOnEnter() + } + + setUserSearchInput(value) + } + + const parseContent = (event: Event) => { + try { + return JSON.parse(event.content) + } catch (e) { + return undefined + console.error(e) + } + } + return ( <> {isLoading && } @@ -852,42 +1049,108 @@ export const CreatePage = () => { moveSigner={moveSigner} />
+
- setUserInput(e.target.value)} - onKeyDown={handleInputKeyDown} - error={!!error} + x} + getOptionLabel={(option) => { + let label: string = (option as FoundUser).npub + + const contentJson = parseContent(option as FoundUser) + + if (contentJson?.name) { + label = contentJson.name + } else { + label = option as string + } + + return label + }} + renderOption={(props, option) => { + const { ...optionProps } = props + + const contentJson = parseContent(option) + + return ( + img': { mr: 2, flexShrink: 0 } }} + {...optionProps} + key={option.pubkey} + > + + +
+ {contentJson.name}{' '} + {usersPubkey === option.pubkey ? ( + + Me + + ) : ( + '' + )}{' '} + ({truncate(option.npub, { length: 16 })}) +
+
+ ) + }} + renderInput={(params) => ( + + )} />
- - + {!pastedUserNpubOrNip05 ? ( + + ) : ( + + )}
diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index 0a022eb..f30ecdd 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -33,7 +33,8 @@ import { signEventForMetaFile, updateUsersAppData, findOtherUserMarks, - timeout + timeout, + processMarks } from '../../utils' import { Container } from '../../components/Container' import { DisplayMeta } from './internal/displayMeta' @@ -54,6 +55,7 @@ import { } from '../../utils/file.ts' import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts' import { generateTimestamp } from '../../utils/opentimestamps.ts' +import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx' enum SignedStatus { Fully_Signed, @@ -237,6 +239,43 @@ export const SignPage = () => { const signedMarks = extractMarksFromSignedMeta(meta) const currentUserMarks = getCurrentUserMarks(metaMarks, signedMarks) const otherUserMarks = findOtherUserMarks(signedMarks, usersPubkey!) + + if (meta.keys) { + for (let i = 0; i < otherUserMarks.length; i++) { + const m = otherUserMarks[i] + const { sender, keys } = meta.keys + const usersNpub = hexToNpub(usersPubkey) + if (usersNpub in keys) { + const encryptionKey = await nostrController + .nip04Decrypt(sender, keys[usersNpub]) + .catch((err) => { + console.log( + 'An error occurred in decrypting encryption key', + err + ) + return null + }) + + try { + const { fetchAndDecrypt } = MARK_TYPE_CONFIG[m.type] || {} + if ( + typeof fetchAndDecrypt === 'function' && + m.value && + encryptionKey + ) { + const decrypted = await fetchAndDecrypt( + m.value, + encryptionKey + ) + otherUserMarks[i].value = decrypted + } + } catch (error) { + console.error(`Error during mark fetchAndDecrypt phase`, error) + } + } + } + } + setOtherUserMarks(otherUserMarks) setCurrentUserMarks(currentUserMarks) setIsMarksCompleted(isCurrentUserMarksComplete(currentUserMarks)) @@ -248,6 +287,7 @@ export const SignPage = () => { if (meta) { handleUpdatedMeta(meta) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [meta, usersPubkey]) const handleDownload = async () => { @@ -552,8 +592,8 @@ export const SignPage = () => { setIsLoading(true) setLoadingSpinnerDesc('Signing nostr event') - - const prevSig = getPrevSignersSig(hexToNpub(usersPubkey!)) + const usersNpub = hexToNpub(usersPubkey!) + const prevSig = getPrevSignersSig(usersNpub) if (!prevSig) { setIsLoading(false) toast.error('Previous signature is invalid') @@ -562,7 +602,26 @@ export const SignPage = () => { const marks = getSignerMarksForMeta() || [] - const signedEvent = await signEventForMeta({ prevSig, marks }) + let encryptionKey: string | undefined + if (meta.keys) { + const { sender, keys } = meta.keys + encryptionKey = await nostrController + .nip04Decrypt(sender, keys[usersNpub]) + .catch((err) => { + // Log and display an error message if decryption fails + console.log('An error occurred in decrypting encryption key', err) + toast.error('An error occurred in decrypting encryption key') + return undefined + }) + } + + const processedMarks = await processMarks(marks, encryptionKey) + + const signedEvent = await signEventForMeta({ + prevSig, + marks: processedMarks + }) + if (!signedEvent) return const updatedMeta = updateMetaSignatures(meta, signedEvent) diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index 2d341c1..515a257 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -55,7 +55,7 @@ import { } from '@fortawesome/free-solid-svg-icons' import { upgradeAndVerifyTimestamp } from '../../utils/opentimestamps.ts' import _ from 'lodash' -import { MARK_TYPE_CONFIG } from '../../components/getMarkComponents.tsx' +import { MarkRender } from '../../components/MarkTypeStrategy/MarkRender.tsx' interface PdfViewProps { files: CurrentUserFile[] @@ -115,8 +115,6 @@ const SlimPdfView = ({ alt={`page ${i} of ${file.name}`} /> {marks.map((m) => { - const { render: MarkRenderComponent } = - MARK_TYPE_CONFIG[m.type] || {} return (
- {typeof MarkRenderComponent !== 'undefined' && ( - - )} +
) })} @@ -390,82 +390,77 @@ export const VerifyPage = () => { setIsLoading(true) setLoadingSpinnerDesc('Fetching file from file server') - axios - .get(zipUrl, { + try { + const res = await axios.get(zipUrl, { responseType: 'arraybuffer' }) - .then(async (res) => { - const fileName = zipUrl.split('/').pop() - const file = new File([res.data], fileName!) - const encryptedArrayBuffer = await file.arrayBuffer() - const arrayBuffer = await decryptArrayBuffer( - encryptedArrayBuffer, - encryptionKey - ).catch((err) => { - console.log('err in decryption:>> ', err) + const fileName = zipUrl.split('/').pop() + const file = new File([res.data], fileName!) + + const encryptedArrayBuffer = await file.arrayBuffer() + const arrayBuffer = await decryptArrayBuffer( + encryptedArrayBuffer, + encryptionKey + ).catch((err) => { + console.log('err in decryption:>> ', err) + toast.error(err.message || 'An error occurred in decrypting file.') + return null + }) + + if (arrayBuffer) { + const zip = await JSZip.loadAsync(arrayBuffer).catch((err) => { + console.log('err in loading zip file :>> ', err) toast.error( - err.message || 'An error occurred in decrypting file.' + err.message || 'An error occurred in loading zip file.' ) return null }) - if (arrayBuffer) { - const zip = await JSZip.loadAsync(arrayBuffer).catch((err) => { - console.log('err in loading zip file :>> ', err) - toast.error( - err.message || 'An error occurred in loading zip file.' - ) - return null - }) + if (!zip) return - if (!zip) return + const files: { [fileName: string]: SigitFile } = {} + const fileHashes: { [key: string]: string | null } = {} + const fileNames = Object.values(zip.files).map( + (entry) => entry.name + ) - const files: { [fileName: string]: SigitFile } = {} - const fileHashes: { [key: string]: string | null } = {} - const fileNames = Object.values(zip.files).map( - (entry) => entry.name + // generate hashes for all entries in files folder of zipArchive + // these hashes can be used to verify the originality of files + for (const fileName of fileNames) { + const arrayBuffer = await readContentOfZipEntry( + zip, + fileName, + 'arraybuffer' ) - // generate hashes for all entries in files folder of zipArchive - // these hashes can be used to verify the originality of files - for (const fileName of fileNames) { - const arrayBuffer = await readContentOfZipEntry( - zip, - fileName, - 'arraybuffer' + if (arrayBuffer) { + files[fileName] = await convertToSigitFile( + arrayBuffer, + fileName! ) + const hash = await getHash(arrayBuffer) - if (arrayBuffer) { - files[fileName] = await convertToSigitFile( - arrayBuffer, - fileName! - ) - const hash = await getHash(arrayBuffer) - - if (hash) { - fileHashes[fileName.replace(/^files\//, '')] = hash - } - } else { - fileHashes[fileName.replace(/^files\//, '')] = null + if (hash) { + fileHashes[fileName.replace(/^files\//, '')] = hash } + } else { + fileHashes[fileName.replace(/^files\//, '')] = null } - - setCurrentFileHashes(fileHashes) - setFiles(files) - - setIsLoading(false) } - }) - .catch((err) => { - console.error(`error occurred in getting file from ${zipUrl}`, err) - toast.error( - err.message || `error occurred in getting file from ${zipUrl}` - ) - }) - .finally(() => { + + setCurrentFileHashes(fileHashes) + setFiles(files) setIsLoading(false) - }) + } + } catch (err) { + const message = `error occurred in getting file from ${zipUrl}` + console.error(message, err) + if (err instanceof Error) toast.error(err.message) + else toast.error(message) + } finally { + setIsLoading(false) + } } processSigit() diff --git a/src/routes/index.tsx b/src/routes/index.tsx index be7e43b..f3580f9 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,16 +1,4 @@ -import { CreatePage } from '../pages/create' -import { HomePage } from '../pages/home' -import { LandingPage } from '../pages/landing' -import { ProfilePage } from '../pages/profile' -import { SettingsPage } from '../pages/settings/Settings' -import { CacheSettingsPage } from '../pages/settings/cache' -import { NostrLoginPage } from '../pages/settings/nostrLogin' -import { ProfileSettingsPage } from '../pages/settings/profile' -import { RelaysPage } from '../pages/settings/relays' -import { SignPage } from '../pages/sign' -import { VerifyPage } from '../pages/verify' import { hexToNpub } from '../utils' -import { Route, RouteProps } from 'react-router-dom' export const appPrivateRoutes = { homePage: '/', @@ -39,93 +27,3 @@ export const getProfileRoute = (hexKey: string) => export const getProfileSettingsRoute = (hexKey: string) => appPrivateRoutes.profileSettings.replace(':npub', hexToNpub(hexKey)) - -/** - * Helper type allows for extending react-router-dom's **RouteProps** with generic type - */ -type CustomRouteProps = T & - Omit & { - children?: Array> - } - -/** - * This function maps over nested routes with optional condition for rendering - * @param {CustomRouteProps[]} routes - routes list - * @param {RenderConditionCallbackFn} renderConditionCallbackFn - render condition callback (default true) - */ -export function recursiveRouteRenderer( - routes?: CustomRouteProps[], - renderConditionCallbackFn: (route: CustomRouteProps) => boolean = () => - true -) { - if (!routes) return null - - // Callback allows us to pass arbitrary conditions for each route's rendering - // Skipping the callback will by default evaluate to true (show route) - return routes.map((route, index) => - renderConditionCallbackFn(route) ? ( - - {recursiveRouteRenderer(route.children, renderConditionCallbackFn)} - - ) : null - ) -} - -type PublicRouteProps = CustomRouteProps<{ - hiddenWhenLoggedIn?: boolean -}> - -export const publicRoutes: PublicRouteProps[] = [ - { - path: appPublicRoutes.landingPage, - hiddenWhenLoggedIn: true, - element: - }, - { - path: appPublicRoutes.profile, - element: - }, - { - path: `${appPublicRoutes.verify}/:id?`, - element: - } -] - -export const privateRoutes = [ - { - path: appPrivateRoutes.homePage, - element: - }, - { - path: appPrivateRoutes.create, - element: - }, - { - path: `${appPrivateRoutes.sign}/:id?`, - element: - }, - { - path: appPrivateRoutes.settings, - element: - }, - { - path: appPrivateRoutes.profileSettings, - element: - }, - { - path: appPrivateRoutes.cacheSettings, - element: - }, - { - path: appPrivateRoutes.relays, - element: - }, - { - path: appPrivateRoutes.nostrLogin, - element: - } -] diff --git a/src/routes/util.tsx b/src/routes/util.tsx new file mode 100644 index 0000000..efde6c5 --- /dev/null +++ b/src/routes/util.tsx @@ -0,0 +1,103 @@ +import { Route, RouteProps } from 'react-router-dom' +import { appPrivateRoutes, appPublicRoutes } from '.' +import { CreatePage } from '../pages/create' +import { HomePage } from '../pages/home' +import { LandingPage } from '../pages/landing' +import { ProfilePage } from '../pages/profile' +import { CacheSettingsPage } from '../pages/settings/cache' +import { NostrLoginPage } from '../pages/settings/nostrLogin' +import { ProfileSettingsPage } from '../pages/settings/profile' +import { RelaysPage } from '../pages/settings/relays' +import { SettingsPage } from '../pages/settings/Settings' +import { SignPage } from '../pages/sign' +import { VerifyPage } from '../pages/verify' + +/** + * Helper type allows for extending react-router-dom's **RouteProps** with generic type + */ +type CustomRouteProps = T & + Omit & { + children?: Array> + } + +/** + * This function maps over nested routes with optional condition for rendering + * @param {CustomRouteProps[]} routes - routes list + * @param {RenderConditionCallbackFn} renderConditionCallbackFn - render condition callback (default true) + */ +export function recursiveRouteRenderer( + routes?: CustomRouteProps[], + renderConditionCallbackFn: (route: CustomRouteProps) => boolean = () => + true +) { + if (!routes) return null + + // Callback allows us to pass arbitrary conditions for each route's rendering + // Skipping the callback will by default evaluate to true (show route) + return routes.map((route, index) => + renderConditionCallbackFn(route) ? ( + + {recursiveRouteRenderer(route.children, renderConditionCallbackFn)} + + ) : null + ) +} + +type PublicRouteProps = CustomRouteProps<{ + hiddenWhenLoggedIn?: boolean +}> + +export const publicRoutes: PublicRouteProps[] = [ + { + path: appPublicRoutes.landingPage, + hiddenWhenLoggedIn: true, + element: + }, + { + path: appPublicRoutes.profile, + element: + }, + { + path: appPublicRoutes.verify, + element: + } +] + +export const privateRoutes = [ + { + path: appPrivateRoutes.homePage, + element: + }, + { + path: appPrivateRoutes.create, + element: + }, + { + path: `${appPrivateRoutes.sign}/:id?`, + element: + }, + { + path: appPrivateRoutes.settings, + element: + }, + { + path: appPrivateRoutes.profileSettings, + element: + }, + { + path: appPrivateRoutes.cacheSettings, + element: + }, + { + path: appPrivateRoutes.relays, + element: + }, + { + path: appPrivateRoutes.nostrLogin, + element: + } +] diff --git a/src/types/mark.ts b/src/types/mark.ts index ec1c162..df733d6 100644 --- a/src/types/mark.ts +++ b/src/types/mark.ts @@ -28,24 +28,3 @@ export interface MarkRect { width: number height: number } - -export interface MarkInputProps { - value: string - handler: (value: string) => void - placeholder?: string - userMark?: CurrentUserMark -} - -export interface MarkRenderProps { - value?: string - mark: Mark -} - -export interface MarkConfig { - input: React.FC - render?: React.FC -} - -export type MarkConfigs = { - [key in MarkType]?: MarkConfig -} diff --git a/src/utils/const.ts b/src/utils/const.ts index 38f138e..bf38404 100644 --- a/src/utils/const.ts +++ b/src/utils/const.ts @@ -112,3 +112,13 @@ export const MOST_COMMON_MEDIA_TYPES = new Map([ ['3g2', 'video/3gpp2'], // 3GPP2 audio/video container ['7z', 'application/x-7z-compressed'] // 7-zip archive ]) + +export const SIGNATURE_PAD_OPTIONS = { + minWidth: 0.5, + maxWidth: 3 +} as const + +export const SIGNATURE_PAD_SIZE = { + width: 600, + height: 300 +} diff --git a/src/utils/file.ts b/src/utils/file.ts index c08d5e7..e8c4e33 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -1,7 +1,11 @@ +import { MARK_TYPE_CONFIG } from '../components/MarkTypeStrategy/MarkStrategy.tsx' +import { NostrController } from '../controllers/NostrController.ts' +import store from '../store/store.ts' import { Meta } from '../types' import { PdfPage } from '../types/drawing.ts' import { MOST_COMMON_MEDIA_TYPES } from './const.ts' import { extractMarksFromSignedMeta } from './mark.ts' +import { hexToNpub } from './nostr.ts' import { addMarks, groupMarksByFileNamePage, @@ -21,7 +25,49 @@ export const getZipWithFiles = async ( for (const [fileName, file] of Object.entries(files)) { // Handle PDF Files, add marks if (file.isPdf && fileName in marksByFileNamePage) { - const blob = await addMarks(file, marksByFileNamePage[fileName]) + const marksToAdd = marksByFileNamePage[fileName] + if (meta.keys) { + for (let i = 0; i < marks.length; i++) { + const m = marks[i] + const { sender, keys } = meta.keys + const usersPubkey = store.getState().auth.usersPubkey! + const usersNpub = hexToNpub(usersPubkey) + if (usersNpub in keys) { + const encryptionKey = await NostrController.getInstance() + .nip04Decrypt(sender, keys[usersNpub]) + .catch((err) => { + console.log( + 'An error occurred in decrypting encryption key', + err + ) + return null + }) + + try { + const { fetchAndDecrypt } = MARK_TYPE_CONFIG[m.type] || {} + if ( + typeof fetchAndDecrypt === 'function' && + m.value && + encryptionKey + ) { + // Fetch and decrypt the original file + const link = m.value.split('/') + const decrypted = await fetchAndDecrypt(m.value, encryptionKey) + + // Save decrypted + zip.file( + `signatures/${link[link.length - 1]}.json`, + new Blob([decrypted]) + ) + marks[i].value = decrypted + } + } catch (error) { + console.error(`Error during mark fetchAndDecrypt phase`, error) + } + } + } + } + const blob = await addMarks(file, marksToAdd) zip.file(`marked/${fileName}`, blob) } diff --git a/src/utils/index.ts b/src/utils/index.ts index accc008..791c39b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -11,3 +11,4 @@ export * from './string' export * from './url' export * from './utils' export * from './zip' +export * from './const' diff --git a/src/utils/mark.ts b/src/utils/mark.ts index 4eca3a8..2c0b339 100644 --- a/src/utils/mark.ts +++ b/src/utils/mark.ts @@ -1,4 +1,4 @@ -import { CurrentUserMark, Mark, MarkLocation } from '../types/mark.ts' +import { CurrentUserMark, Mark } from '../types/mark.ts' import { hexToNpub } from './nostr.ts' import { Meta, SignedEventContent } from '../types' import { Event } from 'nostr-tools' @@ -24,7 +24,7 @@ import { faStamp, faTableCellsLarge } from '@fortawesome/free-solid-svg-icons' -import { Config, optimize } from 'svgo' +import { MARK_TYPE_CONFIG } from '../components/MarkTypeStrategy/MarkStrategy.tsx' /** * Takes in an array of Marks already filtered by User. @@ -266,22 +266,38 @@ export const getToolboxLabelByMarkType = (markType: MarkType) => { return DEFAULT_TOOLBOX.find((t) => t.identifier === markType)?.label } -export const optimizeSVG = (location: MarkLocation, paths: string[]) => { - const svgContent = `${paths.map((path) => ``).join('')}` - const optimizedSVG = optimize(svgContent, { - multipass: true, // Optimize multiple times if needed - floatPrecision: 2 // Adjust precision - } as Config) +export const getOptimizedPathsWithStrokeWidth = (svgString: string) => { + const parser = new DOMParser() + const xmlDoc = parser.parseFromString(svgString, 'image/svg+xml') + const paths = xmlDoc.querySelectorAll('path') + const tuples: string[][] = [] + paths.forEach((path) => { + const d = path.getAttribute('d') ?? '' + const strokeWidth = path.getAttribute('stroke-width') ?? '' + tuples.push([d, strokeWidth]) + }) - return optimizedSVG.data + return tuples } -export const getOptimizedPaths = (svgString: string) => { - const regex = / d="([^"]*)"/g - const matches = [...svgString.matchAll(regex)] - const pathValues = matches.map((match) => match[1]) +export const processMarks = async (marks: Mark[], encryptionKey?: string) => { + const _marks = [...marks] + for (let i = 0; i < _marks.length; i++) { + const mark = _marks[i] + const hasProcess = + mark.type in MARK_TYPE_CONFIG && + typeof MARK_TYPE_CONFIG[mark.type]?.encryptAndUpload === 'function' - return pathValues + if (hasProcess) { + const value = mark.value! + const processFn = MARK_TYPE_CONFIG[mark.type]?.encryptAndUpload + if (processFn) { + mark.value = await processFn(value, encryptionKey) + } + } + } + + return _marks } export { diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts index b66bc73..88cdc67 100644 --- a/src/utils/pdf.ts +++ b/src/utils/pdf.ts @@ -11,6 +11,9 @@ if (!PDFJS.GlobalWorkerOptions.workerPort) { import fontkit from '@pdf-lib/fontkit' import defaultFont from '../assets/fonts/roboto-regular.ttf' +import { BasicPoint } from 'signature_pad/dist/types/point' +import SignaturePad from 'signature_pad' +import { SIGNATURE_PAD_OPTIONS, SIGNATURE_PAD_SIZE } from './const.ts' /** * Defined font size used when generating a PDF. Currently it is difficult to fully @@ -132,17 +135,18 @@ export const addMarks = async ( for (let i = 0; i < pages.length; i++) { if (marksPerPage && Object.hasOwn(marksPerPage, i)) { - marksPerPage[i]?.forEach((mark) => { + for (let j = 0; j < marksPerPage[i].length; j++) { + const mark = marksPerPage[i][j] switch (mark.type) { case MarkType.SIGNATURE: - drawSignatureText(mark, pages[i]) + await embedSignaturePng(mark, pages[i], pdf) break default: drawMarkText(mark, pages[i], robotoFont) break } - }) + } } } @@ -254,18 +258,41 @@ async function embedFont(pdf: PDFDocument) { return embeddedFont } -const drawSignatureText = (mark: Mark, page: PDFPage) => { +const embedSignaturePng = async ( + mark: Mark, + page: PDFPage, + pdf: PDFDocument +) => { const { location } = mark const { height } = page.getSize() - // Convert the mark location origin (top, left) to PDF origin (bottom, left) - const x = location.left - const y = height - location.top - if (hasValue(mark)) { - const paths = JSON.parse(mark.value!) - paths.forEach((d: string) => { - page.drawSvgPath(d, { x, y }) + const data = JSON.parse(mark.value!).map((p: BasicPoint[]) => ({ + points: p + })) + const canvas = document.createElement('canvas') + canvas.width = SIGNATURE_PAD_SIZE.width + canvas.height = SIGNATURE_PAD_SIZE.height + const pad = new SignaturePad(canvas, SIGNATURE_PAD_OPTIONS) + pad.fromData(data) + const signatureImage = await pdf.embedPng(pad.toDataURL()) + + const scaled = signatureImage.scaleToFit(location.width, location.height) + + // Convert the mark location origin (top, left) to PDF origin (bottom, left) + // and center the image + const x = location.left + (location.width - scaled.width) / 2 + const y = + height - + location.top - + location.height + + (location.height - scaled.height) / 2 + + page.drawImage(signatureImage, { + x, + y, + width: scaled.width, + height: scaled.height }) } } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 1348642..bcf2960 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -2,6 +2,18 @@ import { TimeoutError } from '../types/errors/TimeoutError.ts' import { CurrentUserFile } from '../types/file.ts' import { SigitFile } from './file.ts' +export const debounceCustom = void>( + fn: T, + delay: number +): ((...args: Parameters) => void) => { + let timerId: ReturnType + + return (...args: Parameters) => { + clearTimeout(timerId) + timerId = setTimeout(() => fn(...args), delay) + } +} + export const compareObjects = ( obj1: object | null | undefined, obj2: object | null | undefined