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/pages/create/index.tsx b/src/pages/create/index.tsx index c57bda0..897ebec 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,7 +20,11 @@ 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, @@ -41,7 +52,8 @@ import { updateUsersAppData, uploadToFileStorage, DEFAULT_TOOLBOX, - settleAllFullfilfedPromises + settleAllFullfilfedPromises, + debounceCustom } from '../../utils' import { Container } from '../../components/Container' import fileListStyles from '../../components/FileList/style.module.scss' @@ -58,13 +70,21 @@ 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 nostrDefaultImage from '../../assets/images/nostr-logo.png' +import * as React from 'react' + +interface FoundUsers extends Event { + npub: string +} export const CreatePage = () => { const navigate = useNavigate() @@ -87,6 +107,90 @@ export const CreatePage = () => { } const [userInput, setUserInput] = useState('') + const [userSearchInput, setUserSearchInput] = useState('') + + const [userRole, setUserRole] = 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 nostrController = NostrController.getInstance() + + const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>( + {} + ) + const [drawnFiles, setDrawnFiles] = useState([]) + const [parsingPdf, setIsParsing] = useState(false) + + const [selectedTool, setSelectedTool] = useState() + + const [foundUsers, setFoundUsers] = useState([]) + const [searchUsersLoading, setSearchUsersLoading] = useState(false) + + /** + * Fired when user select + */ + const handleSearchUserChange = useCallback( + (_event: React.SyntheticEvent, value: string | FoundUsers | null) => { + if (typeof value === 'object') { + const ndkEvent = value as FoundUsers + 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) => { + const fineFilteredEvents = events + .filter((event) => event.content.includes(`"name":"${searchTerm}`)) + .map((event) => ({ + ...event, + npub: hexToNpub(event.pubkey) + })) + + setFoundUsers(fineFilteredEvents) + }) + .catch((error) => { + console.error(error) + }) + .finally(() => { + setSearchUsersLoading(false) + }) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedHandleSearchUsers = useCallback( + debounceCustom((value: string) => { + if (foundUsers.length === 0) handleSearchUsers(value) + }, 1000), + [] + ) + const handleInputKeyDown = (event: React.KeyboardEvent) => { if ( event.code === KeyboardCode.Enter || @@ -96,26 +200,11 @@ export const CreatePage = () => { handleAddUser() } } - const [userRole, setUserRole] = 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 nostrController = NostrController.getInstance() - - const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>( - {} - ) - const [drawnFiles, setDrawnFiles] = useState([]) - const [parsingPdf, setIsParsing] = useState(false) 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 +224,6 @@ export const CreatePage = () => { } }, [selectedFiles]) - const [selectedTool, setSelectedTool] = useState() - /** * Changes the drawing tool * @param drawTool to draw with @@ -789,6 +876,14 @@ export const CreatePage = () => { } } + const parseContent = (event: Event) => { + try { + return JSON.parse(event.content) + } catch (e) { + console.error(e) + } + } + return ( <> {isLoading && } @@ -852,6 +947,82 @@ export const CreatePage = () => { moveSigner={moveSigner} /> + +
+
+ x} + getOptionLabel={(option) => { + let label = (option as FoundUsers).npub + + const contentJson = parseContent(option as FoundUsers) + label = contentJson.name + + return label + }} + renderOption={(props, option) => { + const { ...optionProps } = props + + const contentJson = parseContent(option) + + return ( + img': { mr: 2, flexShrink: 0 } }} + {...optionProps} + key={option.pubkey} + > + + (currentTarget.src = nostrDefaultImage) + } + alt="" + /> + {contentJson.name} ( + {truncate(option.npub, { length: 16 })}) + + ) + }} + renderInput={(params) => ( + setUserSearchInput(e.target.value)} + onChange={(e) => { + const value = e.target.value + setUserSearchInput(value) + debouncedHandleSearchUsers(value) + }} + /> + )} + /> +
+ +
+
{ value={userInput} onChange={(e) => setUserInput(e.target.value)} onKeyDown={handleInputKeyDown} + disabled={searchUsersLoading} error={!!error} />
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