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..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/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