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 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: [],
content: `${hostname}-${timestamp}`,
created_at: timestamp

View File

@ -206,15 +206,15 @@ export class MetadataController {
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 = {
kinds: [kinds.ShortTextNote],
kinds: [kinds.Metadata],
authors: [hexKey]
}
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)
if (events && events.length) {
// 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 type { Identifier, XYCoord } from 'dnd-core'
import { useDrag, useDrop } from 'react-dnd'
import saveAs from 'file-saver'
import CopyModal from '../../components/copyModal'
export const CreatePage = () => {
const navigate = useNavigate()
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [openCopyModal, setOpenCopyModel] = useState(false)
const [textToCopy, setTextToCopy] = useState('')
const [authUrl, setAuthUrl] = useState<string>()
@ -329,58 +333,65 @@ export const CreatePage = () => {
const encryptedArrayBuffer = await encryptArrayBuffer(
arraybuffer,
encryptionKey
)
).finally(() => setIsLoading(false))
const blob = new Blob([encryptedArrayBuffer])
setLoadingSpinnerDesc('Uploading zip file to file storage.')
const fileUrl = await uploadToFileStorage(blob, nostrController)
.then((url) => {
toast.success('zip file uploaded to file storage')
return url
})
.catch((err) => {
console.log('err in upload:>> ', err)
setIsLoading(false)
toast.error(err.message || 'Error occurred in uploading zip file')
return null
})
if (navigator.onLine) {
setIsLoading(true)
setLoadingSpinnerDesc('Uploading zip file to file storage.')
const fileUrl = await uploadToFileStorage(blob, nostrController)
.then((url) => {
toast.success('zip file uploaded to file storage')
return url
})
.catch((err) => {
console.log('err in upload:>> ', err)
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
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
// send DM to first signer if exists
if (signers.length > 0) {
await sendDM(
fileUrl,
encryptionKey,
viewer.pubkey,
signers[0].pubkey,
nostrController,
false,
true,
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(
`${appPrivateRoutes.sign}?file=${encodeURIComponent(
fileUrl
)}&key=${encodeURIComponent(encryptionKey)}`
)
navigate(
`${appPrivateRoutes.sign}?file=${encodeURIComponent(
fileUrl
)}&key=${encodeURIComponent(encryptionKey)}`
)
} else {
saveAs(blob, 'request.sigit')
setTextToCopy(encryptionKey)
setOpenCopyModel(true)
}
}
if (authUrl) {
@ -471,6 +482,15 @@ export const CreatePage = () => {
</>
)}
</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,
HourglassTop
} from '@mui/icons-material'
import CopyModal from '../../components/copyModal'
enum SignedStatus {
Fully_Signed,
User_Is_Next_Signer,
@ -79,6 +79,8 @@ export const SignPage = () => {
const [isLoading, setIsLoading] = useState(true)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [openCopyModal, setOpenCopyModel] = useState(false)
const [textToCopy, setTextToCopy] = useState('')
const [meta, setMeta] = useState<Meta | null>(null)
const [signedStatus, setSignedStatus] = useState<SignedStatus>()
@ -98,6 +100,9 @@ export const SignPage = () => {
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 [authUrl, setAuthUrl] = useState<string>()
@ -167,7 +172,20 @@ export const SignPage = () => {
// there's no signer just viewers. So its 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(() => {
const fileUrl = searchParams.get('file')
@ -236,6 +254,7 @@ export const SignPage = () => {
if (!zip) return
setZip(zip)
setDisplayInput(false)
setLoadingSpinnerDesc('Parsing meta.json')
@ -414,75 +433,79 @@ export const SignPage = () => {
const blob = new Blob([encryptedArrayBuffer])
setLoadingSpinnerDesc('Uploading zip file to file storage.')
const fileUrl = await uploadToFileStorage(blob, nostrController)
.then((url) => {
toast.success('zip file uploaded to file storage')
return url
})
.catch((err) => {
console.log('err in upload:>> ', err)
setIsLoading(false)
toast.error(err.message || 'Error occurred in uploading zip file')
return null
})
if (navigator.onLine) {
setLoadingSpinnerDesc('Uploading zip file to file storage.')
const fileUrl = await uploadToFileStorage(blob, nostrController)
.then((url) => {
toast.success('zip file uploaded to file storage')
return url
})
.catch((err) => {
console.log('err in upload:>> ', err)
setIsLoading(false)
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
const usersNpub = hexToNpub(usersPubkey!)
const lastSignerIndex = signers.length - 1
const signerIndex = signers.indexOf(usersNpub)
const isLastSigner = signerIndex === lastSignerIndex
// check if the current user is the last signer
const usersNpub = hexToNpub(usersPubkey!)
const lastSignerIndex = signers.length - 1
const signerIndex = signers.indexOf(usersNpub)
const isLastSigner = signerIndex === lastSignerIndex
// if current user is the last signer, then send DMs to all signers and viewers
if (isLastSigner) {
const userSet = new Set<`npub1${string}`>()
// if current user is the last signer, then send DMs to all signers and viewers
if (isLastSigner) {
const userSet = new Set<`npub1${string}`>()
if (submittedBy) {
userSet.add(hexToNpub(submittedBy))
}
if (submittedBy) {
userSet.add(hexToNpub(submittedBy))
}
signers.forEach((signer) => {
userSet.add(signer)
})
signers.forEach((signer) => {
userSet.add(signer)
})
viewers.forEach((viewer) => {
userSet.add(viewer)
})
viewers.forEach((viewer) => {
userSet.add(viewer)
})
const users = Array.from(userSet)
const users = Array.from(userSet)
for (const user of users) {
// todo: execute in parallel
for (const user of users) {
// todo: execute in parallel
await sendDM(
fileUrl,
key,
npubToHex(user)!,
nostrController,
false,
setAuthUrl
)
}
} else {
const nextSigner = signers[signerIndex + 1]
await sendDM(
fileUrl,
key,
npubToHex(user)!,
npubToHex(nextSigner)!,
nostrController,
false,
true,
setAuthUrl
)
}
setIsLoading(false)
// update search params with updated file url and encryption key
setSearchParams({
file: fileUrl,
key: key
})
} else {
const nextSigner = signers[signerIndex + 1]
await sendDM(
fileUrl,
key,
npubToHex(nextSigner)!,
nostrController,
true,
setAuthUrl
)
handleDecryptedArrayBuffer(arrayBuffer).finally(() => setIsLoading(false))
}
setIsLoading(false)
// update search params with updated file url and encryption key
setSearchParams({
file: fileUrl,
key: key
})
}
const handleExport = async () => {
@ -549,6 +572,37 @@ export const SignPage = () => {
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 prevSig will be used in the content of the provided signer's signedEvent
@ -637,6 +691,7 @@ export const SignPage = () => {
<Box className={styles.inputBlock}>
<MuiFileInput
placeholder="Select file"
inputProps={{ accept: '.sigit' }}
value={selectedFile}
onChange={(value) => setSelectedFile(value)}
/>
@ -675,6 +730,7 @@ export const SignPage = () => {
nextSigner={nextSinger}
getPrevSignersSig={getPrevSignersSig}
/>
{signedStatus === SignedStatus.Fully_Signed && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleExport} variant="contained">
@ -690,9 +746,24 @@ export const SignPage = () => {
</Button>
</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>
<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
const event: EventTemplate = {
kind: 1, // Event type for meta file
kind: 27235, // Event type for meta file
content: content, // content for event
created_at: Math.floor(Date.now() / 1000), // Current timestamp
tags: []