feat: add the ability to create and sign while user is offline #85

Merged
s merged 5 commits from issue-73 into staging 2024-05-28 10:27:40 +00:00
7 changed files with 266 additions and 97 deletions

View File

@ -0,0 +1,27 @@
import { Dialog, DialogContent, DialogTitle } from '@mui/material'
import { CopyToClipboard } from './copyToClipboard'
interface CopyModalProps {
open: boolean
handleClose: () => void
title: string
textToCopy: string
}
export const CopyModal = ({
open,
handleClose,
title,
textToCopy
}: CopyModalProps) => {
return (
<Dialog open={open} onClose={handleClose} maxWidth="xs">
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<CopyToClipboard textToCopy={textToCopy} />
</DialogContent>
</Dialog>
)
}
export default CopyModal

View File

@ -0,0 +1,51 @@
import { ContentCopy } from '@mui/icons-material/'
import { Box, IconButton, Typography } from '@mui/material'
import { toast } from 'react-toastify'
type Props = {
textToCopy: string
}
export const CopyToClipboard = ({ textToCopy }: Props) => {
const handleCopyClick = () => {
navigator.clipboard.writeText(textToCopy)
toast.success('Copied to clipboard', {
autoClose: 1000,
hideProgressBar: true
})
}
return (
<Box
sx={{
s marked this conversation as resolved
Review

it would be nice to move css logic into separate file

it would be nice to move css logic into separate file
Review

it would be nice to move css logic into separate file

This is a simple utility component, I think it's fine to add inline style in this case. Otherwise, I'll create a folder and separate .scss file just for 1 class

> it would be nice to move css logic into separate file This is a simple utility component, I think it's fine to add inline style in this case. Otherwise, I'll create a folder and separate .scss file just for 1 class
display: 'flex',
alignItems: 'center',
width: '100%'
}}
>
<Typography
onClick={(e) => {
e.stopPropagation()
handleCopyClick()
}}
component="label"
sx={{
flex: '1',
overflow: 'auto',
whiteSpace: 'nowrap',
cursor: 'pointer'
}}
>
{textToCopy}
</Typography>
<IconButton
onClick={(e) => {
e.stopPropagation()
handleCopyClick()
}}
>
<ContentCopy />
</IconButton>
</Box>
)
}

View File

@ -58,7 +58,7 @@ export class AuthController {
const { hostname } = window.location const { hostname } = window.location
const authEvent: EventTemplate = { const authEvent: EventTemplate = {
kind: 1, kind: 27235,
s marked this conversation as resolved
Review

it would be nice to use kinds object from nostr-tools

it would be nice to use `kinds` object from `nostr-tools`
Review

it would be nice to use kinds object from nostr-tools

This change isn't related to this PR. It appeared here because staging wasn't in sync with main

> it would be nice to use `kinds` object from `nostr-tools` This change isn't related to this PR. It appeared here because staging wasn't in sync with main
tags: [], tags: [],
content: `${hostname}-${timestamp}`, content: `${hostname}-${timestamp}`,
created_at: timestamp created_at: timestamp

View File

@ -206,15 +206,15 @@ export class MetadataController {
if (userRelays.length === 0) return null if (userRelays.length === 0) return null
// filter for finding user's first kind 1 event // filter for finding user's first kind 0 event
const eventFilter: Filter = { const eventFilter: Filter = {
kinds: [kinds.ShortTextNote], kinds: [kinds.Metadata],
authors: [hexKey] authors: [hexKey]
} }
const pool = new SimplePool() const pool = new SimplePool()
// find user's kind 1 events published on user's relays // find user's kind 0 events published on user's relays
const events = await pool.querySync(userRelays, eventFilter) const events = await pool.querySync(userRelays, eventFilter)
if (events && events.length) { if (events && events.length) {
// sort events by created_at time in ascending order // sort events by created_at time in ascending order

View File

@ -47,11 +47,15 @@ import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend' import { HTML5Backend } from 'react-dnd-html5-backend'
import type { Identifier, XYCoord } from 'dnd-core' import type { Identifier, XYCoord } from 'dnd-core'
import { useDrag, useDrop } from 'react-dnd' import { useDrag, useDrop } from 'react-dnd'
import saveAs from 'file-saver'
import CopyModal from '../../components/copyModal'
export const CreatePage = () => { export const CreatePage = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [openCopyModal, setOpenCopyModel] = useState(false)
const [textToCopy, setTextToCopy] = useState('')
const [authUrl, setAuthUrl] = useState<string>() const [authUrl, setAuthUrl] = useState<string>()
@ -329,58 +333,65 @@ export const CreatePage = () => {
const encryptedArrayBuffer = await encryptArrayBuffer( const encryptedArrayBuffer = await encryptArrayBuffer(
arraybuffer, arraybuffer,
encryptionKey encryptionKey
) ).finally(() => setIsLoading(false))
const blob = new Blob([encryptedArrayBuffer]) const blob = new Blob([encryptedArrayBuffer])
setLoadingSpinnerDesc('Uploading zip file to file storage.') if (navigator.onLine) {
const fileUrl = await uploadToFileStorage(blob, nostrController) setIsLoading(true)
.then((url) => { setLoadingSpinnerDesc('Uploading zip file to file storage.')
toast.success('zip file uploaded to file storage') const fileUrl = await uploadToFileStorage(blob, nostrController)
return url .then((url) => {
}) toast.success('zip file uploaded to file storage')
.catch((err) => { return url
console.log('err in upload:>> ', err) })
setIsLoading(false) .catch((err) => {
toast.error(err.message || 'Error occurred in uploading zip file') console.log('err in upload:>> ', err)
return null setIsLoading(false)
}) toast.error(err.message || 'Error occurred in uploading zip file')
return null
})
if (!fileUrl) return if (!fileUrl) return
setLoadingSpinnerDesc('Sending DM to signers/viewers') setLoadingSpinnerDesc('Sending DM to signers/viewers')
// send DM to first signer if exists // send DM to first signer if exists
if (signers.length > 0) { if (signers.length > 0) {
await sendDM(
fileUrl,
encryptionKey,
signers[0].pubkey,
nostrController,
true,
setAuthUrl
)
} else {
// send DM to all viewers if no signer
for (const viewer of viewers) {
// todo: execute in parallel
await sendDM( await sendDM(
fileUrl, fileUrl,
encryptionKey, encryptionKey,
viewer.pubkey, signers[0].pubkey,
nostrController, nostrController,
false, true,
setAuthUrl setAuthUrl
) )
} else {
// send DM to all viewers if no signer
for (const viewer of viewers) {
// todo: execute in parallel
await sendDM(
fileUrl,
encryptionKey,
viewer.pubkey,
nostrController,
false,
setAuthUrl
)
}
} }
} setIsLoading(false)
setIsLoading(false)
navigate( navigate(
`${appPrivateRoutes.sign}?file=${encodeURIComponent( `${appPrivateRoutes.sign}?file=${encodeURIComponent(
fileUrl fileUrl
)}&key=${encodeURIComponent(encryptionKey)}` )}&key=${encodeURIComponent(encryptionKey)}`
) )
} else {
saveAs(blob, 'request.sigit')
setTextToCopy(encryptionKey)
setOpenCopyModel(true)
}
} }
if (authUrl) { if (authUrl) {
@ -471,6 +482,15 @@ export const CreatePage = () => {
</> </>
)} )}
</Box> </Box>
<CopyModal
open={openCopyModal}
handleClose={() => {
setOpenCopyModel(false)
navigate(appPrivateRoutes.sign)
}}
title="Decryption key for Sigit file"
textToCopy={textToCopy}
/>
</> </>
) )
} }

View File

@ -59,7 +59,7 @@ import {
Download, Download,
HourglassTop HourglassTop
} from '@mui/icons-material' } from '@mui/icons-material'
import CopyModal from '../../components/copyModal'
enum SignedStatus { enum SignedStatus {
Fully_Signed, Fully_Signed,
User_Is_Next_Signer, User_Is_Next_Signer,
@ -79,6 +79,8 @@ export const SignPage = () => {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [openCopyModal, setOpenCopyModel] = useState(false)
const [textToCopy, setTextToCopy] = useState('')
const [meta, setMeta] = useState<Meta | null>(null) const [meta, setMeta] = useState<Meta | null>(null)
const [signedStatus, setSignedStatus] = useState<SignedStatus>() const [signedStatus, setSignedStatus] = useState<SignedStatus>()
@ -98,6 +100,9 @@ export const SignPage = () => {
const [nextSinger, setNextSinger] = useState<string>() const [nextSinger, setNextSinger] = useState<string>()
// This state variable indicates whether the logged-in user is a signer, a creator, or neither.
const [isSignerOrCreator, setIsSignerOrCreator] = useState(false)
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey) const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const [authUrl, setAuthUrl] = useState<string>() const [authUrl, setAuthUrl] = useState<string>()
@ -167,7 +172,20 @@ export const SignPage = () => {
// there's no signer just viewers. So its fully signed // there's no signer just viewers. So its fully signed
setSignedStatus(SignedStatus.Fully_Signed) setSignedStatus(SignedStatus.Fully_Signed)
} }
}, [signers, signedBy, usersPubkey])
// Determine and set the status of the user
if (submittedBy && usersPubkey && submittedBy === usersPubkey) {
// If the submission was made by the user, set the status to true
setIsSignerOrCreator(true)
} else if (usersPubkey) {
// Convert the user's public key from hex to npub format
const usersNpub = hexToNpub(usersPubkey)
if (signers.includes(usersNpub)) {
// If the user's npub is in the list of signers, set the status to true
setIsSignerOrCreator(true)
}
}
}, [signers, signedBy, usersPubkey, submittedBy])
useEffect(() => { useEffect(() => {
const fileUrl = searchParams.get('file') const fileUrl = searchParams.get('file')
@ -236,6 +254,7 @@ export const SignPage = () => {
if (!zip) return if (!zip) return
setZip(zip) setZip(zip)
setDisplayInput(false)
setLoadingSpinnerDesc('Parsing meta.json') setLoadingSpinnerDesc('Parsing meta.json')
@ -414,75 +433,79 @@ export const SignPage = () => {
const blob = new Blob([encryptedArrayBuffer]) const blob = new Blob([encryptedArrayBuffer])
setLoadingSpinnerDesc('Uploading zip file to file storage.') if (navigator.onLine) {
const fileUrl = await uploadToFileStorage(blob, nostrController) setLoadingSpinnerDesc('Uploading zip file to file storage.')
.then((url) => { const fileUrl = await uploadToFileStorage(blob, nostrController)
toast.success('zip file uploaded to file storage') .then((url) => {
return url toast.success('zip file uploaded to file storage')
}) return url
.catch((err) => { })
console.log('err in upload:>> ', err) .catch((err) => {
setIsLoading(false) console.log('err in upload:>> ', err)
toast.error(err.message || 'Error occurred in uploading zip file') setIsLoading(false)
return null toast.error(err.message || 'Error occurred in uploading zip file')
}) return null
})
if (!fileUrl) return if (!fileUrl) return
// check if the current user is the last signer // check if the current user is the last signer
const usersNpub = hexToNpub(usersPubkey!) const usersNpub = hexToNpub(usersPubkey!)
const lastSignerIndex = signers.length - 1 const lastSignerIndex = signers.length - 1
const signerIndex = signers.indexOf(usersNpub) const signerIndex = signers.indexOf(usersNpub)
const isLastSigner = signerIndex === lastSignerIndex const isLastSigner = signerIndex === lastSignerIndex
// if current user is the last signer, then send DMs to all signers and viewers // if current user is the last signer, then send DMs to all signers and viewers
if (isLastSigner) { if (isLastSigner) {
const userSet = new Set<`npub1${string}`>() const userSet = new Set<`npub1${string}`>()
if (submittedBy) { if (submittedBy) {
userSet.add(hexToNpub(submittedBy)) userSet.add(hexToNpub(submittedBy))
} }
signers.forEach((signer) => { signers.forEach((signer) => {
userSet.add(signer) userSet.add(signer)
}) })
viewers.forEach((viewer) => { viewers.forEach((viewer) => {
userSet.add(viewer) userSet.add(viewer)
}) })
const users = Array.from(userSet) const users = Array.from(userSet)
for (const user of users) { for (const user of users) {
// todo: execute in parallel // todo: execute in parallel
await sendDM(
fileUrl,
key,
npubToHex(user)!,
nostrController,
false,
setAuthUrl
)
}
} else {
const nextSigner = signers[signerIndex + 1]
await sendDM( await sendDM(
fileUrl, fileUrl,
key, key,
npubToHex(user)!, npubToHex(nextSigner)!,
nostrController, nostrController,
false, true,
setAuthUrl setAuthUrl
) )
} }
setIsLoading(false)
// update search params with updated file url and encryption key
setSearchParams({
file: fileUrl,
key: key
})
} else { } else {
const nextSigner = signers[signerIndex + 1] handleDecryptedArrayBuffer(arrayBuffer).finally(() => setIsLoading(false))
await sendDM(
fileUrl,
key,
npubToHex(nextSigner)!,
nostrController,
true,
setAuthUrl
)
} }
setIsLoading(false)
// update search params with updated file url and encryption key
setSearchParams({
file: fileUrl,
key: key
})
} }
const handleExport = async () => { const handleExport = async () => {
@ -549,6 +572,37 @@ export const SignPage = () => {
navigate(appPrivateRoutes.verify) navigate(appPrivateRoutes.verify)
} }
const handleExportSigit = async () => {
if (!zip) return
const arrayBuffer = await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: {
level: 6
}
})
.catch((err) => {
console.log('err in zip:>> ', err)
setIsLoading(false)
toast.error(err.message || 'Error occurred in generating zip file')
return null
})
if (!arrayBuffer) return
const key = await generateEncryptionKey()
setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key)
const blob = new Blob([encryptedArrayBuffer])
saveAs(blob, 'exported.sigit')
setTextToCopy(key)
setOpenCopyModel(true)
}
/** /**
* This function accepts an npub of a signer and return the signature of its previous signer. * This function accepts an npub of a signer and return the signature of its previous signer.
* This prevSig will be used in the content of the provided signer's signedEvent * This prevSig will be used in the content of the provided signer's signedEvent
@ -637,6 +691,7 @@ export const SignPage = () => {
<Box className={styles.inputBlock}> <Box className={styles.inputBlock}>
<MuiFileInput <MuiFileInput
placeholder="Select file" placeholder="Select file"
inputProps={{ accept: '.sigit' }}
value={selectedFile} value={selectedFile}
onChange={(value) => setSelectedFile(value)} onChange={(value) => setSelectedFile(value)}
/> />
@ -675,6 +730,7 @@ export const SignPage = () => {
nextSigner={nextSinger} nextSigner={nextSinger}
getPrevSignersSig={getPrevSignersSig} getPrevSignersSig={getPrevSignersSig}
/> />
{signedStatus === SignedStatus.Fully_Signed && ( {signedStatus === SignedStatus.Fully_Signed && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}> <Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleExport} variant="contained"> <Button onClick={handleExport} variant="contained">
@ -690,9 +746,24 @@ export const SignPage = () => {
</Button> </Button>
</Box> </Box>
)} )}
{isSignerOrCreator &&
signedStatus === SignedStatus.User_Is_Not_Next_Signer && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleExportSigit} variant="contained">
Export Sigit
</Button>
</Box>
)}
</> </>
)} )}
</Box> </Box>
<CopyModal
open={openCopyModal}
handleClose={() => setOpenCopyModel(false)}
title="Decryption key for Sigit file"
textToCopy={textToCopy}
/>
</> </>
) )
} }

View File

@ -189,7 +189,7 @@ export const signEventForMetaFile = async (
) => { ) => {
// Construct the event metadata for the meta file // Construct the event metadata for the meta file
const event: EventTemplate = { const event: EventTemplate = {
kind: 1, // Event type for meta file kind: 27235, // Event type for meta file
content: content, // content for event content: content, // content for event
created_at: Math.floor(Date.now() / 1000), // Current timestamp created_at: Math.floor(Date.now() / 1000), // Current timestamp
tags: [] tags: []