refactor: expand useSigitMeta and update verify w details section

This commit is contained in:
enes 2024-08-13 17:28:14 +02:00
parent e16b8cfe3f
commit b3155cce0d
5 changed files with 376 additions and 261 deletions

View File

@ -0,0 +1,246 @@
import { CheckCircle, Cancel } from '@mui/icons-material'
import { Divider, Tooltip } from '@mui/material'
import { verifyEvent } from 'nostr-tools'
import { useSigitProfiles } from '../../hooks/useSigitProfiles'
import { Meta, SignedEventContent } from '../../types'
import {
extractFileExtensions,
formatTimestamp,
fromUnixTimestamp,
hexToNpub,
npubToHex,
shorten
} from '../../utils'
import { UserAvatar } from '../UserAvatar'
import { useSigitMeta } from '../../hooks/useSigitMeta'
import { UserAvatarGroup } from '../UserAvatarGroup'
import styles from './style.module.scss'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faCalendar,
faCalendarCheck,
faCalendarPlus,
faEye,
faFile,
faFileCircleExclamation
} from '@fortawesome/free-solid-svg-icons'
import { getExtensionIconLabel } from '../getExtensionIconLabel'
import { useSelector } from 'react-redux'
import { State } from '../../store/rootReducer'
interface FileUsersProps {
meta: Meta
}
export const FileUsers = ({ meta }: FileUsersProps) => {
const { usersPubkey } = useSelector((state: State) => state.auth)
const {
submittedBy,
signers,
viewers,
fileHashes,
sig,
docSignatures,
parsedSignatureEvents,
createdAt,
signedStatus,
completedAt
} = useSigitMeta(meta)
const profiles = useSigitProfiles([
...(submittedBy ? [submittedBy] : []),
...signers,
...viewers
])
const userCanSign =
typeof usersPubkey !== 'undefined' &&
signers.includes(hexToNpub(usersPubkey))
const ext = extractFileExtensions(Object.keys(fileHashes))
const getPrevSignersSig = (npub: string) => {
// if user is first signer then use creator's signature
if (signers[0] === npub) {
return sig
}
// find the index of signer
const currentSignerIndex = signers.findIndex((signer) => signer === npub)
// return null if could not found user in signer's list
if (currentSignerIndex === -1) return null
// find prev signer
const prevSigner = signers[currentSignerIndex - 1]
// get the signature of prev signer
try {
const prevSignersEvent = parsedSignatureEvents[prevSigner]
return prevSignersEvent.sig
} catch (error) {
return null
}
}
const displayUser = (pubkey: string, verifySignature = false) => {
const profile = profiles[pubkey]
let isValidSignature = false
if (verifySignature) {
const npub = hexToNpub(pubkey)
const signedEventString = docSignatures[npub]
if (signedEventString) {
try {
const signedEvent = JSON.parse(signedEventString)
const isVerifiedEvent = verifyEvent(signedEvent)
if (isVerifiedEvent) {
// get the actual signature of prev signer
const prevSignersSig = getPrevSignersSig(npub)
// get the signature of prev signer from the content of current signers signedEvent
try {
const obj: SignedEventContent = JSON.parse(signedEvent.content)
if (
obj.prevSig &&
prevSignersSig &&
obj.prevSig === prevSignersSig
) {
isValidSignature = true
}
} catch (error) {
isValidSignature = false
}
}
} catch (error) {
console.error(
`An error occurred in parsing and verifying the signature event for ${pubkey}`,
error
)
}
}
}
return (
<>
<UserAvatar
pubkey={pubkey}
name={
profile?.display_name || profile?.name || shorten(hexToNpub(pubkey))
}
image={profile?.picture}
/>
{verifySignature && (
<>
{isValidSignature && (
<Tooltip title="Valid signature">
<CheckCircle sx={{ color: 'green' }} />
</Tooltip>
)}
{!isValidSignature && (
<Tooltip title="Invalid signature">
<Cancel sx={{ color: 'red' }} />
</Tooltip>
)}
</>
)}
</>
)
}
return submittedBy ? (
<div className={styles.container}>
<div className={styles.section}>
<p>Signers</p>
{displayUser(submittedBy)}
{submittedBy && signers.length ? (
<Divider orientation="vertical" flexItem />
) : null}
<UserAvatarGroup max={7}>
{signers.length > 0 &&
signers.map((signer) => (
<span key={signer}>{displayUser(npubToHex(signer)!, true)}</span>
))}
{viewers.length > 0 &&
viewers.map((viewer) => (
<span key={viewer}>{displayUser(npubToHex(viewer)!)}</span>
))}
</UserAvatarGroup>
</div>
<div className={styles.section}>
<p>Details</p>
<Tooltip
title={'Publication date'}
placement="top"
arrow
disableInteractive
>
<span className={styles.detailsItem}>
<FontAwesomeIcon icon={faCalendarPlus} />{' '}
{createdAt ? formatTimestamp(createdAt) : <>&mdash;</>}
</span>
</Tooltip>
<Tooltip
title={'Completion date'}
placement="top"
arrow
disableInteractive
>
<span className={styles.detailsItem}>
<FontAwesomeIcon icon={faCalendarCheck} />{' '}
{completedAt ? formatTimestamp(completedAt) : <>&mdash;</>}
</span>
</Tooltip>
{/* User signed date */}
{userCanSign ? (
<Tooltip
title={'Your signature date'}
placement="top"
arrow
disableInteractive
>
<span className={styles.detailsItem}>
<FontAwesomeIcon icon={faCalendar} />{' '}
{hexToNpub(usersPubkey) in parsedSignatureEvents ? (
parsedSignatureEvents[hexToNpub(usersPubkey)].created_at ? (
formatTimestamp(
fromUnixTimestamp(
parsedSignatureEvents[hexToNpub(usersPubkey)].created_at
)
)
) : (
<>&mdash;</>
)
) : (
<>&mdash;</>
)}
</span>
</Tooltip>
) : null}
<span className={styles.detailsItem}>
<FontAwesomeIcon icon={faEye} /> {signedStatus}
</span>
{ext.length > 0 ? (
<span className={styles.detailsItem}>
{ext.length > 1 ? (
<>
<FontAwesomeIcon icon={faFile} /> Multiple File Types
</>
) : (
getExtensionIconLabel(ext[0])
)}
</span>
) : (
<>
<FontAwesomeIcon icon={faFileCircleExclamation} /> &mdash;
</>
)}
</div>
</div>
) : undefined
}

View File

@ -0,0 +1,41 @@
@import '../../styles/colors.scss';
.container {
border-radius: 4px;
background: $overlay-background-color;
padding: 15px;
display: flex;
flex-direction: column;
grid-gap: 25px;
font-size: 14px;
}
.section {
display: flex;
flex-direction: column;
grid-gap: 10px;
}
.detailsItem {
transition: ease 0.2s;
color: rgba(0, 0, 0, 0.5);
font-size: 14px;
align-items: center;
border-radius: 4px;
padding: 5px;
display: flex;
align-items: center;
justify-content: start;
> :first-child {
padding: 5px;
margin-right: 10px;
}
&:hover {
background: $primary-main;
color: white;
}
}

View File

@ -4,7 +4,7 @@ import { Mark } from '../types/mark'
import { import {
fromUnixTimestamp, fromUnixTimestamp,
hexToNpub, hexToNpub,
parseCreateSignatureEvent, parseNostrEvent,
parseCreateSignatureEventContent, parseCreateSignatureEventContent,
SigitMetaParseError, SigitMetaParseError,
SigitStatus, SigitStatus,
@ -21,7 +21,7 @@ import { NostrController } from '../controllers'
* Flattened interface that combines properties `Meta`, `CreateSignatureEventContent`, * Flattened interface that combines properties `Meta`, `CreateSignatureEventContent`,
* and `Event` (event's fields are made optional and pubkey and created_at replaced with our versions) * and `Event` (event's fields are made optional and pubkey and created_at replaced with our versions)
*/ */
interface FlatMeta export interface FlatMeta
extends Meta, extends Meta,
CreateSignatureEventContent, CreateSignatureEventContent,
Partial<Omit<Event, 'pubkey' | 'created_at'>> { Partial<Omit<Event, 'pubkey' | 'created_at'>> {
@ -37,6 +37,12 @@ interface FlatMeta
// Decryption // Decryption
encryptionKey: string | null encryptionKey: string | null
// Parsed Document Signatures
parsedSignatureEvents: { [signer: `npub1${string}`]: Event }
// Calculated completion time
completedAt?: number
// Calculated status fields // Calculated status fields
signedStatus: SigitStatus signedStatus: SigitStatus
signersStatus: { signersStatus: {
@ -67,6 +73,12 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
const [title, setTitle] = useState<string>('') const [title, setTitle] = useState<string>('')
const [zipUrl, setZipUrl] = useState<string>('') const [zipUrl, setZipUrl] = useState<string>('')
const [parsedSignatureEvents, setParsedSignatureEvents] = useState<{
[signer: `npub1${string}`]: Event
}>({})
const [completedAt, setCompletedAt] = useState<number>()
const [signedStatus, setSignedStatus] = useState<SigitStatus>( const [signedStatus, setSignedStatus] = useState<SigitStatus>(
SigitStatus.Partial SigitStatus.Partial
) )
@ -80,9 +92,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
if (!meta) return if (!meta) return
;(async function () { ;(async function () {
try { try {
const createSignatureEvent = await parseCreateSignatureEvent( const createSignatureEvent = await parseNostrEvent(meta.createSignature)
meta.createSignature
)
const { kind, tags, created_at, pubkey, id, sig, content } = const { kind, tags, created_at, pubkey, id, sig, content } =
createSignatureEvent createSignatureEvent
@ -131,13 +141,22 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
} }
} }
// Parse each signature event and set signer status // Temp. map to hold events
const parsedSignatureEventsMap = new Map<`npub1${string}`, Event>()
for (const npub in meta.docSignatures) { for (const npub in meta.docSignatures) {
try { try {
const event = await parseCreateSignatureEvent( // Parse each signature event
const event = await parseNostrEvent(
meta.docSignatures[npub as `npub1${string}`] meta.docSignatures[npub as `npub1${string}`]
) )
const isValidSignature = verifyEvent(event) const isValidSignature = verifyEvent(event)
// Save events to a map, to save all at once outside loop
// We need the object to find completedAt
// Avoided using parsedSignatureEvents due to useEffect deps
parsedSignatureEventsMap.set(npub as `npub1${string}`, event)
setSignersStatus((prev) => { setSignersStatus((prev) => {
return { return {
...prev, ...prev,
@ -155,6 +174,11 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
}) })
} }
} }
setParsedSignatureEvents(
Object.fromEntries(parsedSignatureEventsMap.entries())
)
const signedBy = Object.keys(meta.docSignatures) as `npub1${string}`[] const signedBy = Object.keys(meta.docSignatures) as `npub1${string}`[]
const isCompletelySigned = signers.every((signer) => const isCompletelySigned = signers.every((signer) =>
signedBy.includes(signer) signedBy.includes(signer)
@ -162,6 +186,20 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
setSignedStatus( setSignedStatus(
isCompletelySigned ? SigitStatus.Complete : SigitStatus.Partial isCompletelySigned ? SigitStatus.Complete : SigitStatus.Partial
) )
// Check if all signers signed and are valid
if (isCompletelySigned) {
setCompletedAt(
fromUnixTimestamp(
signedBy.reduce((p, c) => {
return Math.max(
p,
parsedSignatureEventsMap.get(c)?.created_at || 0
)
}, 0)
)
)
}
} catch (error) { } catch (error) {
if (error instanceof SigitMetaParseError) { if (error instanceof SigitMetaParseError) {
toast.error(error.message) toast.error(error.message)
@ -189,6 +227,8 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
markConfig, markConfig,
title, title,
zipUrl, zipUrl,
parsedSignatureEvents,
completedAt,
signedStatus, signedStatus,
signersStatus, signersStatus,
encryptionKey encryptionKey

View File

@ -1,36 +1,20 @@
import { import { Box, Button, Tooltip, Typography, useTheme } from '@mui/material'
Box,
Button,
List,
ListItem,
ListSubheader,
Tooltip,
Typography,
useTheme
} from '@mui/material'
import JSZip from 'jszip' import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input' import { MuiFileInput } from 'mui-file-input'
import { Event, verifyEvent } from 'nostr-tools' import { Event, verifyEvent } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { UserAvatar } from '../../components/UserAvatar'
import { NostrController } from '../../controllers' import { NostrController } from '../../controllers'
import { import { CreateSignatureEventContent, Meta } from '../../types'
CreateSignatureEventContent,
Meta,
SignedEventContent
} from '../../types'
import { import {
decryptArrayBuffer, decryptArrayBuffer,
extractMarksFromSignedMeta, extractMarksFromSignedMeta,
getHash, getHash,
hexToNpub, hexToNpub,
unixNow, unixNow,
npubToHex,
parseJson, parseJson,
readContentOfZipEntry, readContentOfZipEntry,
shorten,
signEventForMetaFile signEventForMetaFile
} from '../../utils' } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
@ -50,7 +34,8 @@ import { getLastSignersSig } from '../../utils/sign.ts'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import { Container } from '../../components/Container' import { Container } from '../../components/Container'
import { useSigitMeta } from '../../hooks/useSigitMeta.tsx' import { useSigitMeta } from '../../hooks/useSigitMeta.tsx'
import { useSigitProfiles } from '../../hooks/useSigitProfiles.tsx' import { Files } from '../../layouts/Files.tsx'
import { FileUsers } from '../../components/FilesUsers.tsx/index.tsx'
export const VerifyPage = () => { export const VerifyPage = () => {
const theme = useTheme() const theme = useTheme()
@ -67,11 +52,6 @@ export const VerifyPage = () => {
const { submittedBy, zipUrl, encryptionKey, signers, viewers, fileHashes } = const { submittedBy, zipUrl, encryptionKey, signers, viewers, fileHashes } =
useSigitMeta(meta) useSigitMeta(meta)
const profiles = useSigitProfiles([
...(submittedBy ? [submittedBy] : []),
...signers,
...viewers
])
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
@ -283,35 +263,6 @@ export const VerifyPage = () => {
setIsLoading(false) setIsLoading(false)
} }
const getPrevSignersSig = (npub: string) => {
if (!meta) return null
// if user is first signer then use creator's signature
if (signers[0] === npub) {
try {
const createSignatureEvent: Event = JSON.parse(meta.createSignature)
return createSignatureEvent.sig
} catch (error) {
return null
}
}
// find the index of signer
const currentSignerIndex = signers.findIndex((signer) => signer === npub)
// return null if could not found user in signer's list
if (currentSignerIndex === -1) return null
// find prev signer
const prevSigner = signers[currentSignerIndex - 1]
// get the signature of prev signer
try {
const prevSignersEvent: Event = JSON.parse(meta.docSignatures[prevSigner])
return prevSignersEvent.sig
} catch (error) {
return null
}
}
const handleExport = async () => { const handleExport = async () => {
if (Object.entries(files).length === 0 || !meta || !usersPubkey) return if (Object.entries(files).length === 0 || !meta || !usersPubkey) return
@ -379,76 +330,6 @@ export const VerifyPage = () => {
setIsLoading(false) setIsLoading(false)
} }
const displayUser = (pubkey: string, verifySignature = false) => {
const profile = profiles[pubkey]
let isValidSignature = false
if (verifySignature) {
const npub = hexToNpub(pubkey)
const signedEventString = meta ? meta.docSignatures[npub] : null
if (signedEventString) {
try {
const signedEvent = JSON.parse(signedEventString)
const isVerifiedEvent = verifyEvent(signedEvent)
if (isVerifiedEvent) {
// get the actual signature of prev signer
const prevSignersSig = getPrevSignersSig(npub)
// get the signature of prev signer from the content of current signers signedEvent
try {
const obj: SignedEventContent = JSON.parse(signedEvent.content)
if (
obj.prevSig &&
prevSignersSig &&
obj.prevSig === prevSignersSig
) {
isValidSignature = true
}
} catch (error) {
isValidSignature = false
}
}
} catch (error) {
console.error(
`An error occurred in parsing and verifying the signature event for ${pubkey}`,
error
)
}
}
}
return (
<>
<UserAvatar
pubkey={pubkey}
name={
profile?.display_name || profile?.name || shorten(hexToNpub(pubkey))
}
image={profile?.picture}
/>
{verifySignature && (
<>
{isValidSignature && (
<Tooltip title="Valid signature">
<CheckCircle sx={{ color: theme.palette.success.light }} />
</Tooltip>
)}
{!isValidSignature && (
<Tooltip title="Invalid signature">
<Cancel sx={{ color: theme.palette.error.main }} />
</Tooltip>
)}
</>
)}
</>
)
}
const displayExportedBy = () => { const displayExportedBy = () => {
if (!meta || !meta.exportSignature) return null if (!meta || !meta.exportSignature) return null
@ -458,7 +339,7 @@ export const VerifyPage = () => {
const exportSignatureEvent = JSON.parse(exportSignatureString) as Event const exportSignatureEvent = JSON.parse(exportSignatureString) as Event
if (verifyEvent(exportSignatureEvent)) { if (verifyEvent(exportSignatureEvent)) {
return displayUser(exportSignatureEvent.pubkey) // return displayUser(exportSignatureEvent.pubkey)
} else { } else {
toast.error(`Invalid export signature!`) toast.error(`Invalid export signature!`)
return ( return (
@ -505,109 +386,9 @@ export const VerifyPage = () => {
)} )}
{meta && ( {meta && (
<> <Files
<List left={
sx={{ <>
bgcolor: 'background.paper',
marginTop: 2
}}
subheader={
<ListSubheader className={styles.subHeader}>
Meta Info
</ListSubheader>
}
>
{submittedBy && (
<ListItem
sx={{
marginTop: 1,
gap: '15px'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Submitted By
</Typography>
{displayUser(submittedBy)}
</ListItem>
)}
<ListItem
sx={{
marginTop: 1,
gap: '15px'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Exported By
</Typography>
{displayExportedBy()}
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleExport} variant="contained">
Export Sigit
</Button>
</Box>
</ListItem>
{signers.length > 0 && (
<ListItem
sx={{
marginTop: 1,
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Signers
</Typography>
<ul className={styles.usersList}>
{signers.map((signer) => (
<li
key={signer}
style={{
color: textColor,
display: 'flex',
alignItems: 'center',
gap: '15px'
}}
>
{displayUser(npubToHex(signer)!, true)}
</li>
))}
</ul>
</ListItem>
)}
{viewers.length > 0 && (
<ListItem
sx={{
marginTop: 1,
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Viewers
</Typography>
<ul className={styles.usersList}>
{viewers.map((viewer) => (
<li key={viewer} style={{ color: textColor }}>
{displayUser(npubToHex(viewer)!)}
</li>
))}
</ul>
</ListItem>
)}
<ListItem
sx={{
marginTop: 1,
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Files
</Typography>
<Box className={styles.filesWrapper}> <Box className={styles.filesWrapper}>
{Object.entries(currentFileHashes).map( {Object.entries(currentFileHashes).map(
([filename, hash], index) => { ([filename, hash], index) => {
@ -643,9 +424,17 @@ export const VerifyPage = () => {
} }
)} )}
</Box> </Box>
</ListItem> {displayExportedBy()}
</List> <Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
</> <Button onClick={handleExport} variant="contained">
Export Sigit
</Button>
</Box>
</>
}
right={<FileUsers meta={meta} />}
content={<div style={{ height: '300vh' }}></div>}
/>
)} )}
</Container> </Container>
</> </>

View File

@ -62,7 +62,7 @@ function handleError(error: unknown): Error {
// Reuse common error messages for meta parsing // Reuse common error messages for meta parsing
export enum SigitMetaParseErrorType { export enum SigitMetaParseErrorType {
'PARSE_ERROR_SIGNATURE_EVENT' = 'error occurred in parsing the create signature event', 'PARSE_ERROR_EVENT' = 'error occurred in parsing the create signature event',
'PARSE_ERROR_SIGNATURE_EVENT_CONTENT' = "err in parsing the createSignature event's content" 'PARSE_ERROR_SIGNATURE_EVENT_CONTENT' = "err in parsing the createSignature event's content"
} }
@ -76,24 +76,19 @@ export interface SigitCardDisplayInfo {
} }
/** /**
* Wrapper for createSignatureEvent parse that throws custom SigitMetaParseError with cause and context * Wrapper for event parser that throws custom SigitMetaParseError with cause and context
* @param raw Raw string for parsing * @param raw Raw string for parsing
* @returns parsed Event * @returns parsed Event
*/ */
export const parseCreateSignatureEvent = async ( export const parseNostrEvent = async (raw: string): Promise<Event> => {
raw: string
): Promise<Event> => {
try { try {
const createSignatureEvent = await parseJson<Event>(raw) const event = await parseJson<Event>(raw)
return createSignatureEvent return event
} catch (error) { } catch (error) {
throw new SigitMetaParseError( throw new SigitMetaParseError(SigitMetaParseErrorType.PARSE_ERROR_EVENT, {
SigitMetaParseErrorType.PARSE_ERROR_SIGNATURE_EVENT, cause: handleError(error),
{ context: raw
cause: handleError(error), })
context: raw
}
)
} }
} }
@ -135,9 +130,7 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => {
} }
try { try {
const createSignatureEvent = await parseCreateSignatureEvent( const createSignatureEvent = await parseNostrEvent(meta.createSignature)
meta.createSignature
)
// created_at in nostr events are stored in seconds // created_at in nostr events are stored in seconds
sigitInfo.createdAt = fromUnixTimestamp(createSignatureEvent.created_at) sigitInfo.createdAt = fromUnixTimestamp(createSignatureEvent.created_at)
@ -147,13 +140,7 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => {
) )
const files = Object.keys(createSignatureContent.fileHashes) const files = Object.keys(createSignatureContent.fileHashes)
const extensions = files.reduce((result: string[], file: string) => { const extensions = extractFileExtensions(files)
const extension = file.split('.').pop()
if (extension) {
result.push(extension)
}
return result
}, [])
const signedBy = Object.keys(meta.docSignatures) as `npub1${string}`[] const signedBy = Object.keys(meta.docSignatures) as `npub1${string}`[]
const isCompletelySigned = createSignatureContent.signers.every((signer) => const isCompletelySigned = createSignatureContent.signers.every((signer) =>
@ -179,3 +166,15 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => {
} }
} }
} }
export const extractFileExtensions = (fileNames: string[]) => {
const extensions = fileNames.reduce((result: string[], file: string) => {
const extension = file.split('.').pop()
if (extension) {
result.push(extension)
}
return result
}, [])
return extensions
}