Merge branch 'staging' into licence
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 32s

This commit is contained in:
b 2024-08-21 07:30:21 +00:00
commit 2d82521641
68 changed files with 3817 additions and 2240 deletions

View File

@ -8,7 +8,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 25", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 2",
"lint:fix": "eslint . --fix --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint:fix": "eslint . --fix --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:staged": "eslint --fix --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint:staged": "eslint --fix --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"formatter:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"", "formatter:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",

View File

@ -1,9 +1,6 @@
import { useEffect, useState } from 'react' import { Meta } from '../../types'
import { Meta, ProfileMetadata } from '../../types' import { SigitCardDisplayInfo, SigitStatus, SignStatus } from '../../utils'
import { SigitCardDisplayInfo, SigitStatus } from '../../utils'
import { Event, kinds } from 'nostr-tools'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { MetadataController } from '../../controllers'
import { formatTimestamp, hexToNpub, npubToHex, shorten } from '../../utils' import { formatTimestamp, hexToNpub, npubToHex, shorten } from '../../utils'
import { appPublicRoutes, appPrivateRoutes } from '../../routes' import { appPublicRoutes, appPrivateRoutes } from '../../routes'
import { Button, Divider, Tooltip } from '@mui/material' import { Button, Divider, Tooltip } from '@mui/material'
@ -16,12 +13,13 @@ import {
faFile faFile
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { UserAvatar } from '../UserAvatar'
import { UserAvatarGroup } from '../UserAvatarGroup' import { UserAvatarGroup } from '../UserAvatarGroup'
import styles from './style.module.scss' import styles from './style.module.scss'
import { TooltipChild } from '../TooltipChild' import { TooltipChild } from '../TooltipChild'
import { getExtensionIconLabel } from '../getExtensionIconLabel' import { getExtensionIconLabel } from '../getExtensionIconLabel'
import { useSigitProfiles } from '../../hooks/useSigitProfiles'
import { useSigitMeta } from '../../hooks/useSigitMeta'
type SigitProps = { type SigitProps = {
meta: Meta meta: Meta
@ -35,64 +33,16 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
submittedBy, submittedBy,
signers, signers,
signedStatus, signedStatus,
fileExtensions fileExtensions,
isValid
} = parsedMeta } = parsedMeta
const [profiles, setProfiles] = useState<{ [key: string]: ProfileMetadata }>( const { signersStatus } = useSigitMeta(meta)
{}
)
useEffect(() => { const profiles = useSigitProfiles([
const hexKeys = new Set<string>([ ...(submittedBy ? [submittedBy] : []),
...signers.map((signer) => npubToHex(signer)!) ...signers
]) ])
if (submittedBy) {
hexKeys.add(npubToHex(submittedBy)!)
}
const metadataController = new MetadataController()
const handleMetadataEvent = (key: string) => (event: Event) => {
const metadataContent =
metadataController.extractProfileMetadataContent(event)
if (metadataContent) {
setProfiles((prev) => ({
...prev,
[key]: metadataContent
}))
}
}
const handleEventListener =
(key: string) => (kind: number, event: Event) => {
if (kind === kinds.Metadata) {
handleMetadataEvent(key)(event)
}
}
hexKeys.forEach((key) => {
if (!(key in profiles)) {
metadataController.on(key, handleEventListener(key))
metadataController
.findMetadata(key)
.then((metadataEvent) => {
if (metadataEvent) handleMetadataEvent(key)(metadataEvent)
})
.catch((err) => {
console.error(`error occurred in finding metadata for: ${key}`, err)
})
}
})
return () => {
hexKeys.forEach((key) => {
metadataController.off(key, handleEventListener(key))
})
}
}, [submittedBy, signers, profiles])
return ( return (
<div className={styles.itemWrapper}> <div className={styles.itemWrapper}>
@ -112,6 +62,7 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
const profile = profiles[submittedBy] const profile = profiles[submittedBy]
return ( return (
<Tooltip <Tooltip
key={submittedBy}
title={ title={
profile?.display_name || profile?.display_name ||
profile?.name || profile?.name ||
@ -122,7 +73,11 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
disableInteractive disableInteractive
> >
<TooltipChild> <TooltipChild>
<UserAvatar pubkey={submittedBy} image={profile?.picture} /> <DisplaySigner
status={isValid ? SignStatus.Signed : SignStatus.Invalid}
profile={profile}
pubkey={submittedBy}
/>
</TooltipChild> </TooltipChild>
</Tooltip> </Tooltip>
) )
@ -130,7 +85,7 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
{submittedBy && signers.length ? ( {submittedBy && signers.length ? (
<Divider orientation="vertical" flexItem /> <Divider orientation="vertical" flexItem />
) : null} ) : null}
<UserAvatarGroup className={styles.signers} max={7}> <UserAvatarGroup max={7}>
{signers.map((signer) => { {signers.map((signer) => {
const pubkey = npubToHex(signer)! const pubkey = npubToHex(signer)!
const profile = profiles[pubkey] const profile = profiles[pubkey]
@ -147,7 +102,7 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
> >
<TooltipChild> <TooltipChild>
<DisplaySigner <DisplaySigner
meta={meta} status={signersStatus[signer]}
profile={profile} profile={profile}
pubkey={pubkey} pubkey={pubkey}
/> />

View File

@ -93,26 +93,6 @@
grid-gap: 10px; grid-gap: 10px;
} }
.signers {
padding: 0 0 0 10px;
> * {
transition: margin ease 0.2s;
margin: 0 0 0 -10px;
position: relative;
z-index: 1;
&:first-child {
margin-left: -10px !important;
}
}
> *:hover,
> *:focus-within {
margin: 0 15px 0 5px;
z-index: 2;
}
}
.details { .details {
color: rgba(0, 0, 0, 0.3); color: rgba(0, 0, 0, 0.3);
font-size: 14px; font-size: 14px;

View File

@ -1,58 +1,51 @@
import { Badge } from '@mui/material' import { Badge } from '@mui/material'
import { Event, verifyEvent } from 'nostr-tools' import { ProfileMetadata } from '../../types'
import { useState, useEffect } from 'react'
import { Meta, ProfileMetadata } from '../../types'
import { hexToNpub, parseJson } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { UserAvatar } from '../UserAvatar' import { UserAvatar } from '../UserAvatar'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheck, faExclamation } from '@fortawesome/free-solid-svg-icons' import {
faCheck,
enum SignStatus { faEllipsis,
Signed = 'Signed', faExclamation,
Pending = 'Pending', faEye,
Invalid = 'Invalid Sign' faHourglass,
} faQuestion
} from '@fortawesome/free-solid-svg-icons'
import { SignStatus } from '../../utils'
import { Spinner } from '../Spinner'
type DisplaySignerProps = { type DisplaySignerProps = {
meta: Meta
profile: ProfileMetadata profile: ProfileMetadata
pubkey: string pubkey: string
status: SignStatus
} }
export const DisplaySigner = ({ export const DisplaySigner = ({
meta, status,
profile, profile,
pubkey pubkey
}: DisplaySignerProps) => { }: DisplaySignerProps) => {
const [signStatus, setSignedStatus] = useState<SignStatus>() const getStatusIcon = (status: SignStatus) => {
switch (status) {
case SignStatus.Signed:
return <FontAwesomeIcon icon={faCheck} />
case SignStatus.Awaiting:
return (
<Spinner>
<FontAwesomeIcon icon={faHourglass} />
</Spinner>
)
case SignStatus.Pending:
return <FontAwesomeIcon icon={faEllipsis} />
case SignStatus.Invalid:
return <FontAwesomeIcon icon={faExclamation} />
case SignStatus.Viewer:
return <FontAwesomeIcon icon={faEye} />
useEffect(() => { default:
if (!meta) return return <FontAwesomeIcon icon={faQuestion} />
const updateSignStatus = async () => {
const npub = hexToNpub(pubkey)
if (npub in meta.docSignatures) {
parseJson<Event>(meta.docSignatures[npub])
.then((event) => {
const isValidSignature = verifyEvent(event)
if (isValidSignature) {
setSignedStatus(SignStatus.Signed)
} else {
setSignedStatus(SignStatus.Invalid)
}
})
.catch((err) => {
console.log(`err in parsing the docSignatures for ${npub}:>> `, err)
setSignedStatus(SignStatus.Invalid)
})
} else {
setSignedStatus(SignStatus.Pending)
}
} }
}
updateSignStatus()
}, [meta, pubkey])
return ( return (
<Badge <Badge
@ -60,16 +53,7 @@ export const DisplaySigner = ({
overlap="circular" overlap="circular"
anchorOrigin={{ vertical: 'top', horizontal: 'right' }} anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
badgeContent={ badgeContent={
signStatus !== SignStatus.Pending && ( <div className={styles.statusBadge}>{getStatusIcon(status)}</div>
<div className={styles.statusBadge}>
{signStatus === SignStatus.Signed && (
<FontAwesomeIcon icon={faCheck} />
)}
{signStatus === SignStatus.Invalid && (
<FontAwesomeIcon icon={faExclamation} />
)}
</div>
)
} }
> >
<UserAvatar pubkey={pubkey} image={profile?.picture} /> <UserAvatar pubkey={pubkey} image={profile?.picture} />

View File

@ -1,40 +1,27 @@
import { import { Close } from '@mui/icons-material'
AccessTime,
CalendarMonth,
ExpandMore,
Gesture,
PictureAsPdf,
Badge,
Work,
Close
} from '@mui/icons-material'
import { import {
Box, Box,
Typography,
Accordion,
AccordionDetails,
AccordionSummary,
CircularProgress, CircularProgress,
Divider,
FormControl, FormControl,
InputLabel, InputLabel,
MenuItem, MenuItem,
Select Select
} from '@mui/material' } from '@mui/material'
import styles from './style.module.scss' import styles from './style.module.scss'
import { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import * as PDFJS from 'pdfjs-dist' import * as PDFJS from 'pdfjs-dist'
import { ProfileMetadata, User } from '../../types' import { ProfileMetadata, User, UserRole } from '../../types'
import { import {
PdfFile, PdfFile,
DrawTool,
MouseState, MouseState,
PdfPage, PdfPage,
DrawnField, DrawnField,
MarkType DrawTool
} from '../../types/drawing' } from '../../types/drawing'
import { truncate } from 'lodash' import { truncate } from 'lodash'
import { hexToNpub } from '../../utils' import { extractFileExtension, hexToNpub } from '../../utils'
import { toPdfFiles } from '../../utils/pdf.ts' import { toPdfFiles } from '../../utils/pdf.ts'
PDFJS.GlobalWorkerOptions.workerSrc = new URL( PDFJS.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs', 'pdfjs-dist/build/pdf.worker.min.mjs',
@ -46,48 +33,14 @@ interface Props {
users: User[] users: User[]
metadata: { [key: string]: ProfileMetadata } metadata: { [key: string]: ProfileMetadata }
onDrawFieldsChange: (pdfFiles: PdfFile[]) => void onDrawFieldsChange: (pdfFiles: PdfFile[]) => void
selectedTool?: DrawTool
} }
export const DrawPDFFields = (props: Props) => { export const DrawPDFFields = (props: Props) => {
const { selectedFiles } = props const { selectedFiles, selectedTool, onDrawFieldsChange, users } = props
const [pdfFiles, setPdfFiles] = useState<PdfFile[]>([]) const [pdfFiles, setPdfFiles] = useState<PdfFile[]>([])
const [parsingPdf, setParsingPdf] = useState<boolean>(false) const [parsingPdf, setParsingPdf] = useState<boolean>(false)
const [showDrawToolBox, setShowDrawToolBox] = useState<boolean>(false)
const [selectedTool, setSelectedTool] = useState<DrawTool | null>()
const [toolbox] = useState<DrawTool[]>([
{
identifier: MarkType.SIGNATURE,
icon: <Gesture />,
label: 'Signature',
active: false
},
{
identifier: MarkType.FULLNAME,
icon: <Badge />,
label: 'Full Name',
active: true
},
{
identifier: MarkType.JOBTITLE,
icon: <Work />,
label: 'Job Title',
active: false
},
{
identifier: MarkType.DATE,
icon: <CalendarMonth />,
label: 'Date',
active: false
},
{
identifier: MarkType.DATETIME,
icon: <AccessTime />,
label: 'Datetime',
active: false
}
])
const [mouseState, setMouseState] = useState<MouseState>({ const [mouseState, setMouseState] = useState<MouseState>({
clicked: false clicked: false
@ -95,6 +48,16 @@ export const DrawPDFFields = (props: Props) => {
useEffect(() => { useEffect(() => {
if (selectedFiles) { if (selectedFiles) {
/**
* Reads the pdf binary files and converts it's pages to images
* creates the pdfFiles object and sets to a state
*/
const parsePdfPages = async () => {
const pdfFiles: PdfFile[] = await toPdfFiles(selectedFiles)
setPdfFiles(pdfFiles)
}
setParsingPdf(true) setParsingPdf(true)
parsePdfPages().finally(() => { parsePdfPages().finally(() => {
@ -104,8 +67,8 @@ export const DrawPDFFields = (props: Props) => {
}, [selectedFiles]) }, [selectedFiles])
useEffect(() => { useEffect(() => {
if (pdfFiles) props.onDrawFieldsChange(pdfFiles) if (pdfFiles) onDrawFieldsChange(pdfFiles)
}, [pdfFiles]) }, [onDrawFieldsChange, pdfFiles])
/** /**
* Drawing events * Drawing events
@ -132,14 +95,14 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event * @param event Mouse event
* @param page PdfPage where press happened * @param page PdfPage where press happened
*/ */
const onMouseDown = (event: any, page: PdfPage) => { const onMouseDown = (
event: React.MouseEvent<HTMLDivElement>,
page: PdfPage
) => {
// Proceed only if left click // Proceed only if left click
if (event.button !== 0) return if (event.button !== 0) return
// Only allow drawing if mouse is not over other drawn element if (!selectedTool) {
const isOverPdfImageWrapper = event.target.tagName === 'IMG'
if (!selectedTool || !isOverPdfImageWrapper) {
return return
} }
@ -185,11 +148,20 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event * @param event Mouse event
* @param page PdfPage where moving is happening * @param page PdfPage where moving is happening
*/ */
const onMouseMove = (event: any, page: PdfPage) => { const onMouseMove = (
event: React.MouseEvent<HTMLDivElement>,
page: PdfPage
) => {
if (mouseState.clicked && selectedTool) { if (mouseState.clicked && selectedTool) {
const lastElementIndex = page.drawnFields.length - 1 const lastElementIndex = page.drawnFields.length - 1
const lastDrawnField = page.drawnFields[lastElementIndex] const lastDrawnField = page.drawnFields[lastElementIndex]
// Return early if we don't have lastDrawnField
// Issue noticed in the console when dragging out of bounds
// to the page below (without releaseing mouse click)
if (!lastDrawnField) return
const { mouseX, mouseY } = getMouseCoordinates(event) const { mouseX, mouseY } = getMouseCoordinates(event)
const width = mouseX - lastDrawnField.left const width = mouseX - lastDrawnField.left
@ -216,7 +188,7 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event * @param event Mouse event
* @param drawnField Which we are moving * @param drawnField Which we are moving
*/ */
const onDrawnFieldMouseDown = (event: any) => { const onDrawnFieldMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation() event.stopPropagation()
// Proceed only if left click // Proceed only if left click
@ -239,11 +211,14 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event * @param event Mouse event
* @param drawnField which we are moving * @param drawnField which we are moving
*/ */
const onDranwFieldMouseMove = (event: any, drawnField: DrawnField) => { const onDrawnFieldMouseMove = (
event: React.MouseEvent<HTMLDivElement>,
drawnField: DrawnField
) => {
if (mouseState.dragging) { if (mouseState.dragging) {
const { mouseX, mouseY, rect } = getMouseCoordinates( const { mouseX, mouseY, rect } = getMouseCoordinates(
event, event,
event.target.parentNode event.currentTarget.parentElement
) )
const coordsOffset = mouseState.coordsInWrapper const coordsOffset = mouseState.coordsInWrapper
@ -272,7 +247,9 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event * @param event Mouse event
* @param drawnField which we are resizing * @param drawnField which we are resizing
*/ */
const onResizeHandleMouseDown = (event: any) => { const onResizeHandleMouseDown = (
event: React.MouseEvent<HTMLSpanElement>
) => {
// Proceed only if left click // Proceed only if left click
if (event.button !== 0) return if (event.button !== 0) return
@ -288,11 +265,17 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event * @param event Mouse event
* @param drawnField which we are resizing * @param drawnField which we are resizing
*/ */
const onResizeHandleMouseMove = (event: any, drawnField: DrawnField) => { const onResizeHandleMouseMove = (
event: React.MouseEvent<HTMLSpanElement>,
drawnField: DrawnField
) => {
if (mouseState.resizing) { if (mouseState.resizing) {
const { mouseX, mouseY } = getMouseCoordinates( const { mouseX, mouseY } = getMouseCoordinates(
event, event,
event.target.parentNode.parentNode // currentTarget = span handle
// 1st parent = drawnField
// 2nd parent = img
event.currentTarget.parentElement?.parentElement
) )
const width = mouseX - drawnField.left const width = mouseX - drawnField.left
@ -313,7 +296,7 @@ export const DrawPDFFields = (props: Props) => {
* @param drawnFileIndex drawn file index * @param drawnFileIndex drawn file index
*/ */
const onRemoveHandleMouseDown = ( const onRemoveHandleMouseDown = (
event: any, event: React.MouseEvent<HTMLSpanElement>,
pdfFileIndex: number, pdfFileIndex: number,
pdfPageIndex: number, pdfPageIndex: number,
drawnFileIndex: number drawnFileIndex: number
@ -331,7 +314,9 @@ export const DrawPDFFields = (props: Props) => {
* so select can work properly * so select can work properly
* @param event Mouse event * @param event Mouse event
*/ */
const onUserSelectHandleMouseDown = (event: any) => { const onUserSelectHandleMouseDown = (
event: React.MouseEvent<HTMLDivElement>
) => {
event.stopPropagation() event.stopPropagation()
} }
@ -341,8 +326,11 @@ export const DrawPDFFields = (props: Props) => {
* @param customTarget mouse coordinates relative to this element, if not provided * @param customTarget mouse coordinates relative to this element, if not provided
* event.target will be used * event.target will be used
*/ */
const getMouseCoordinates = (event: any, customTarget?: any) => { const getMouseCoordinates = (
const target = customTarget ? customTarget : event.target event: React.MouseEvent<HTMLElement>,
customTarget?: HTMLElement | null
) => {
const target = customTarget ? customTarget : event.currentTarget
const rect = target.getBoundingClientRect() const rect = target.getBoundingClientRect()
const mouseX = event.clientX - rect.left //x position within the element. const mouseX = event.clientX - rect.left //x position within the element.
const mouseY = event.clientY - rect.top //y position within the element. const mouseY = event.clientY - rect.top //y position within the element.
@ -354,74 +342,26 @@ export const DrawPDFFields = (props: Props) => {
} }
} }
/**
* Reads the pdf binary files and converts it's pages to images
* creates the pdfFiles object and sets to a state
*/
const parsePdfPages = async () => {
const pdfFiles: PdfFile[] = await toPdfFiles(selectedFiles)
setPdfFiles(pdfFiles)
}
/**
*
* @returns if expanded pdf accordion is present
*/
const hasExpandedPdf = () => {
return !!pdfFiles.filter((pdfFile) => !!pdfFile.expanded).length
}
const handleAccordionExpandChange = (expanded: boolean, pdfFile: PdfFile) => {
pdfFile.expanded = expanded
refreshPdfFiles()
setShowDrawToolBox(hasExpandedPdf())
}
/**
* Changes the drawing tool
* @param drawTool to draw with
*/
const handleToolSelect = (drawTool: DrawTool) => {
// If clicked on the same tool, unselect
if (drawTool.identifier === selectedTool?.identifier) {
setSelectedTool(null)
return
}
setSelectedTool(drawTool)
}
/** /**
* Renders the pdf pages and drawing elements * Renders the pdf pages and drawing elements
*/ */
const getPdfPages = (pdfFile: PdfFile, pdfFileIndex: number) => { const getPdfPages = (pdfFile: PdfFile, pdfFileIndex: number) => {
return ( return (
<Box <>
sx={{
width: '100%'
}}
>
{pdfFile.pages.map((page, pdfPageIndex: number) => { {pdfFile.pages.map((page, pdfPageIndex: number) => {
return ( return (
<div <div
key={pdfPageIndex} key={pdfPageIndex}
style={{
border: '1px solid #c4c4c4',
marginBottom: '10px'
}}
className={`${styles.pdfImageWrapper} ${selectedTool ? styles.drawing : ''}`} className={`${styles.pdfImageWrapper} ${selectedTool ? styles.drawing : ''}`}
onMouseMove={(event) => {
onMouseMove(event, page)
}}
onMouseDown={(event) => {
onMouseDown(event, page)
}}
> >
<img <img
onMouseMove={(event) => {
onMouseMove(event, page)
}}
onMouseDown={(event) => {
onMouseDown(event, page)
}}
draggable="false" draggable="false"
style={{ width: '100%' }}
src={page.image} src={page.image}
/> />
@ -431,7 +371,7 @@ export const DrawPDFFields = (props: Props) => {
key={drawnFieldIndex} key={drawnFieldIndex}
onMouseDown={onDrawnFieldMouseDown} onMouseDown={onDrawnFieldMouseDown}
onMouseMove={(event) => { onMouseMove={(event) => {
onDranwFieldMouseMove(event, drawnField) onDrawnFieldMouseMove(event, drawnField)
}} }}
className={styles.drawingRectangle} className={styles.drawingRectangle}
style={{ style={{
@ -477,36 +417,38 @@ export const DrawPDFFields = (props: Props) => {
labelId="counterparts" labelId="counterparts"
label="Counterparts" label="Counterparts"
> >
{props.users.map((user, index) => { {users
let displayValue = truncate( .filter((u) => u.role === UserRole.signer)
hexToNpub(user.pubkey), .map((user, index) => {
{ let displayValue = truncate(
length: 16 hexToNpub(user.pubkey),
}
)
const metadata = props.metadata[user.pubkey]
if (metadata) {
displayValue = truncate(
metadata.name ||
metadata.display_name ||
metadata.username,
{ {
length: 16 length: 16
} }
) )
}
return ( const metadata = props.metadata[user.pubkey]
<MenuItem
key={index} if (metadata) {
value={hexToNpub(user.pubkey)} displayValue = truncate(
> metadata.name ||
{displayValue} metadata.display_name ||
</MenuItem> metadata.username,
) {
})} length: 16
}
)
}
return (
<MenuItem
key={index}
value={hexToNpub(user.pubkey)}
>
{displayValue}
</MenuItem>
)
})}
</Select> </Select>
</FormControl> </FormControl>
</div> </div>
@ -516,7 +458,7 @@ export const DrawPDFFields = (props: Props) => {
</div> </div>
) )
})} })}
</Box> </>
) )
} }
@ -533,57 +475,38 @@ export const DrawPDFFields = (props: Props) => {
} }
return ( return (
<Box> <div className={styles.view}>
<Box sx={{ mt: 1 }}> {selectedFiles.map((file, i) => {
<Typography sx={{ mb: 1 }}>Draw fields on the PDFs:</Typography> const name = file.name
const extension = extractFileExtension(name)
{pdfFiles.map((pdfFile, pdfFileIndex: number) => { const pdfFile = pdfFiles.find((pdf) => pdf.file.name === name)
return ( return (
<Accordion <React.Fragment key={name}>
key={pdfFileIndex} <div
expanded={pdfFile.expanded} className={`${styles.fileWrapper} ${styles.scrollTarget}`}
onChange={(_event, expanded) => { id={`file-${name}`}
handleAccordionExpandChange(expanded, pdfFile)
}}
> >
<AccordionSummary {pdfFile ? (
expandIcon={<ExpandMore />} getPdfPages(pdfFile, i)
aria-controls={`panel${pdfFileIndex}-content`} ) : (
id={`panel${pdfFileIndex}header`} <div className={styles.otherFile}>
This is a {extension} file
</div>
)}
</div>
{i < selectedFiles.length - 1 && (
<Divider
sx={{
fontSize: '12px',
color: 'rgba(0,0,0,0.15)'
}}
> >
<PictureAsPdf sx={{ mr: 1 }} /> File Separator
{pdfFile.file.name} </Divider>
</AccordionSummary> )}
<AccordionDetails> </React.Fragment>
{getPdfPages(pdfFile, pdfFileIndex)} )
</AccordionDetails> })}
</Accordion> </div>
)
})}
</Box>
{showDrawToolBox && (
<Box className={styles.drawToolBoxContainer}>
<Box className={styles.drawToolBox}>
{toolbox
.filter((drawTool) => drawTool.active)
.map((drawTool: DrawTool, index: number) => {
return (
<Box
key={index}
onClick={() => {
handleToolSelect(drawTool)
}}
className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''}`}
>
{drawTool.icon}
{drawTool.label}
</Box>
)
})}
</Box>
</Box>
)}
</Box>
) )
} }

View File

@ -1,3 +1,5 @@
@import '../../styles/sizes.scss';
.pdfFieldItem { .pdfFieldItem {
background: white; background: white;
padding: 10px; padding: 10px;
@ -5,53 +7,18 @@
cursor: pointer; cursor: pointer;
} }
.drawToolBoxContainer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
z-index: 50;
.drawToolBox {
display: flex;
gap: 10px;
min-width: 100px;
background-color: white;
padding: 15px;
box-shadow: 0 0 10px 1px #0000003b;
border-radius: 4px;
.toolItem {
display: flex;
flex-direction: column;
align-items: center;
border: 1px solid rgba(0, 0, 0, 0.137);
padding: 5px;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
&.selected {
border-color: #01aaad;
color: #01aaad;
}
&:not(.selected) {
&:hover {
border-color: #01aaad79;
}
}
}
}
}
.pdfImageWrapper { .pdfImageWrapper {
position: relative; position: relative;
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
> img {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain; /* Ensure the image fits within the container */
}
&.drawing { &.drawing {
cursor: crosshair; cursor: crosshair;
} }
@ -72,6 +39,10 @@
visibility: hidden; visibility: hidden;
} }
&.edited {
border: 1px dotted #01aaad;
}
.resizeHandle { .resizeHandle {
position: absolute; position: absolute;
right: -5px; right: -5px;
@ -81,7 +52,14 @@
background-color: #fff; background-color: #fff;
border: 1px solid rgb(160, 160, 160); border: 1px solid rgb(160, 160, 160);
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: nwse-resize;
// Increase the area a bit so it's easier to click
&::after {
content: '';
position: absolute;
inset: -14px;
}
} }
.removeHandle { .removeHandle {
@ -112,3 +90,29 @@
padding: 5px 0; padding: 5px 0;
} }
} }
.fileWrapper {
display: flex;
flex-direction: column;
gap: 15px;
position: relative;
scroll-margin-top: $header-height + $body-vertical-padding;
}
.view {
display: flex;
flex-direction: column;
gap: 25px;
}
.otherFile {
border-radius: 4px;
background: rgba(255, 255, 255, 0.5);
height: 100px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: rgba(0, 0, 0, 0.25);
font-size: 14px;
}

View File

@ -0,0 +1,50 @@
import { CurrentUserFile } from '../../types/file.ts'
import styles from './style.module.scss'
import { Button } from '@mui/material'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheck } from '@fortawesome/free-solid-svg-icons'
interface FileListProps {
files: CurrentUserFile[]
currentFile: CurrentUserFile
setCurrentFile: (file: CurrentUserFile) => void
handleDownload: () => void
}
const FileList = ({
files,
currentFile,
setCurrentFile,
handleDownload
}: FileListProps) => {
const isActive = (file: CurrentUserFile) => file.id === currentFile.id
return (
<div className={styles.wrap}>
<div className={styles.container}>
<ul className={styles.files}>
{files.map((file: CurrentUserFile) => (
<li
key={file.id}
className={`${styles.fileItem} ${isActive(file) && styles.active}`}
onClick={() => setCurrentFile(file)}
>
<div className={styles.fileNumber}>{file.id}</div>
<div className={styles.fileInfo}>
<div className={styles.fileName}>{file.filename}</div>
</div>
<div className={styles.fileVisual}>
{file.isHashValid && <FontAwesomeIcon icon={faCheck} />}
</div>
</li>
))}
</ul>
</div>
<Button variant="contained" fullWidth onClick={handleDownload}>
Download Files
</Button>
</div>
)
}
export default FileList

View File

@ -0,0 +1,122 @@
.container {
border-radius: 4px;
background: white;
padding: 15px;
display: flex;
flex-direction: column;
grid-gap: 0px;
}
.filesPageContainer {
width: 100%;
display: grid;
grid-template-columns: 0.75fr 1.5fr 0.75fr;
grid-gap: 30px;
flex-grow: 1;
}
ul {
list-style-type: none; /* Removes bullet points */
margin: 0; /* Removes default margin */
padding: 0; /* Removes default padding */
}
li {
list-style-type: none; /* Removes the bullets */
margin: 0; /* Removes any default margin */
padding: 0; /* Removes any default padding */
}
.wrap {
display: flex;
flex-direction: column;
grid-gap: 15px;
}
.files {
display: flex;
flex-direction: column;
width: 100%;
grid-gap: 15px;
max-height: 350px;
overflow: auto;
padding: 0 5px 0 0;
margin: 0 -5px 0 0;
}
.files::-webkit-scrollbar {
width: 10px;
}
.files::-webkit-scrollbar-track {
background-color: rgba(0, 0, 0, 0.15);
}
.files::-webkit-scrollbar-thumb {
max-width: 10px;
border-radius: 2px;
background-color: #4c82a3;
cursor: grab;
}
.fileItem {
transition: ease 0.2s;
display: flex;
flex-direction: row;
grid-gap: 10px;
border-radius: 4px;
overflow: hidden;
background: #ffffff;
padding: 5px 10px;
align-items: center;
color: rgba(0, 0, 0, 0.5);
cursor: pointer;
flex-grow: 1;
font-size: 16px;
font-weight: 500;
min-height: 45px;
&.active {
background: #4c82a3;
color: white;
}
}
.fileItem:hover {
background: #4c82a3;
color: white;
}
.fileInfo {
flex-grow: 1;
font-size: 16px;
font-weight: 500;
}
.fileName {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 1;
line-clamp: 1;
}
.fileNumber {
font-size: 14px;
font-weight: 500;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 10px;
}
.fileVisual {
display: flex;
flex-shrink: 0;
flex-direction: column;
justify-content: center;
align-items: center;
height: 25px;
width: 25px;
}

View File

@ -0,0 +1,126 @@
import { CurrentUserMark } from '../../types/mark.ts'
import styles from './style.module.scss'
import { MARK_TYPE_TRANSLATION, NEXT, SIGN } from '../../utils/const.ts'
import {
findNextIncompleteCurrentUserMark,
isCurrentUserMarksComplete,
isCurrentValueLast
} from '../../utils'
import React, { useState } from 'react'
interface MarkFormFieldProps {
currentUserMarks: CurrentUserMark[]
handleCurrentUserMarkChange: (mark: CurrentUserMark) => void
handleSelectedMarkValueChange: (
event: React.ChangeEvent<HTMLInputElement>
) => void
handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void
selectedMark: CurrentUserMark
selectedMarkValue: string
}
/**
* Responsible for rendering a form field connected to a mark and keeping track of its value.
*/
const MarkFormField = ({
handleSubmit,
handleSelectedMarkValueChange,
selectedMark,
selectedMarkValue,
currentUserMarks,
handleCurrentUserMarkChange
}: MarkFormFieldProps) => {
const [displayActions, setDisplayActions] = useState(true)
const getSubmitButtonText = () => (isReadyToSign() ? SIGN : NEXT)
const isReadyToSign = () =>
isCurrentUserMarksComplete(currentUserMarks) ||
isCurrentValueLast(currentUserMarks, selectedMark, selectedMarkValue)
const isCurrent = (currentMark: CurrentUserMark) =>
currentMark.id === selectedMark.id
const isDone = (currentMark: CurrentUserMark) =>
isCurrent(currentMark) ? !!selectedMarkValue : currentMark.isCompleted
const findNext = () => {
return (
currentUserMarks[selectedMark.id] ||
findNextIncompleteCurrentUserMark(currentUserMarks)
)
}
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
console.log('handle form submit runs...')
return isReadyToSign()
? handleSubmit(event)
: handleCurrentUserMarkChange(findNext()!)
}
const toggleActions = () => setDisplayActions(!displayActions)
return (
<div className={styles.container}>
<div className={styles.trigger}>
<button
onClick={toggleActions}
className={styles.triggerBtn}
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="-64 0 512 512"
width="1em"
height="1em"
fill="currentColor"
transform={displayActions ? 'rotate(180)' : 'rotate(0)'}
>
<path d="M352 352c-8.188 0-16.38-3.125-22.62-9.375L192 205.3l-137.4 137.4c-12.5 12.5-32.75 12.5-45.25 0s-12.5-32.75 0-45.25l160-160c12.5-12.5 32.75-12.5 45.25 0l160 160c12.5 12.5 12.5 32.75 0 45.25C368.4 348.9 360.2 352 352 352z"></path>
</svg>
</button>
</div>
<div className={`${styles.actions} ${displayActions && styles.expanded}`}>
<div className={styles.actionsWrapper}>
<div className={styles.actionsTop}>
<div className={styles.actionsTopInfo}>
<p className={styles.actionsTopInfoText}>Add your signature</p>
</div>
</div>
<div className={styles.inputWrapper}>
<form onSubmit={(e) => handleFormSubmit(e)}>
<input
className={styles.input}
placeholder={
MARK_TYPE_TRANSLATION[selectedMark.mark.type.valueOf()]
}
onChange={handleSelectedMarkValueChange}
value={selectedMarkValue}
/>
<div className={styles.actionsBottom}>
<button type="submit" className={styles.submitButton}>
{getSubmitButtonText()}
</button>
</div>
</form>
<div className={styles.footerContainer}>
<div className={styles.footer}>
{currentUserMarks.map((mark, index) => {
return (
<div className={styles.pagination} key={index}>
<button
className={`${styles.paginationButton} ${isDone(mark) && styles.paginationButtonDone}`}
onClick={() => handleCurrentUserMarkChange(mark)}
>
{mark.id}
</button>
{isCurrent(mark) && (
<div className={styles.paginationButtonCurrent}></div>
)}
</div>
)
})}
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default MarkFormField

View File

@ -0,0 +1,210 @@
.container {
width: 100%;
display: flex;
flex-direction: column;
position: fixed;
bottom: 0;
right: 0;
left: 0;
align-items: center;
z-index: 1000;
button {
transition: ease 0.2s;
width: auto;
border-radius: 4px;
outline: unset;
border: unset;
background: unset;
color: #ffffff;
background: #4c82a3;
font-weight: 500;
font-size: 14px;
padding: 8px 15px;
white-space: nowrap;
display: flex;
flex-direction: row;
grid-gap: 12px;
justify-content: center;
align-items: center;
text-decoration: unset;
position: relative;
cursor: pointer;
}
button:hover {
background: #5e8eab;
color: white;
}
button:active {
background: #447592;
color: white;
}
}
.actions {
background: white;
width: 100%;
border-radius: 4px;
padding: 10px 20px;
display: none;
flex-direction: column;
align-items: center;
grid-gap: 15px;
box-shadow: 0 -2px 4px 0 rgb(0, 0, 0, 0.1);
max-width: 750px;
&.expanded {
display: flex;
}
}
.actionsWrapper {
display: flex;
flex-direction: column;
grid-gap: 20px;
flex-grow: 1;
width: 100%;
}
.actionsTop {
display: flex;
flex-direction: row;
grid-gap: 10px;
align-items: center;
}
.actionsTopInfo {
flex-grow: 1;
}
.actionsTopInfoText {
font-size: 16px;
color: #434343;
}
.actionsTrigger {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
}
.actionButtons {
display: flex;
flex-direction: row;
grid-gap: 5px;
}
.inputWrapper {
display: flex;
flex-direction: column;
grid-gap: 10px;
}
.textInput {
height: 100px;
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
border: solid 2px #4c82a3;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.input {
border-radius: 4px;
border: solid 1px rgba(0, 0, 0, 0.15);
padding: 5px 10px;
font-size: 16px;
width: 100%;
background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0) 100%),
linear-gradient(white, white);
}
.input:focus {
border: solid 1px rgba(0, 0, 0, 0.15);
outline: none;
background: linear-gradient(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.05) 100%),
linear-gradient(white, white);
}
.actionsBottom {
display: flex;
flex-direction: row;
grid-gap: 5px;
justify-content: center;
align-items: center;
}
.submitButton {
width: 100%;
max-width: 300px;
margin-top: 10px;
}
.footerContainer {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.footer {
display: flex;
flex-direction: row;
grid-gap: 5px;
align-items: start;
justify-content: center;
width: 100%;
}
.pagination {
display: flex;
flex-direction: column;
grid-gap: 5px;
}
.paginationButton {
font-size: 12px;
padding: 5px 10px;
border-radius: 3px;
background: rgba(0, 0, 0, 0.1);
color: rgba(0, 0, 0, 0.5);
}
.paginationButton:hover {
background: #447592;
color: rgba(255, 255, 255, 0.5);
}
.paginationButtonDone {
background: #5e8eab;
color: rgb(255, 255, 255);
}
.paginationButtonCurrent {
height: 2px;
width: 100%;
background: #4c82a3;
}
.trigger {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
}
.triggerBtn {
background: white;
color: #434343;
padding: 5px 30px;
box-shadow: 0px -3px 4px 0 rgb(0, 0, 0, 0.1);
position: absolute;
top: -25px;
}

View File

@ -20,14 +20,13 @@ const PdfMarkItem = ({
}: PdfMarkItemProps) => { }: PdfMarkItemProps) => {
const { location } = userMark.mark const { location } = userMark.mark
const handleClick = () => handleMarkClick(userMark.mark.id) const handleClick = () => handleMarkClick(userMark.mark.id)
const isEdited = () => selectedMark?.mark.id === userMark.mark.id
const getMarkValue = () => const getMarkValue = () =>
selectedMark?.mark.id === userMark.mark.id isEdited() ? selectedMarkValue : userMark.currentValue
? selectedMarkValue
: userMark.mark.value
return ( return (
<div <div
onClick={handleClick} onClick={handleClick}
className={styles.drawingRectangle} className={`${styles.drawingRectangle} ${isEdited() && styles.edited}`}
style={{ style={{
left: inPx(location.left), left: inPx(location.left),
top: inPx(location.top), top: inPx(location.top),

View File

@ -1,23 +1,30 @@
import PdfView from './index.tsx' import PdfView from './index.tsx'
import MarkFormField from '../../pages/sign/MarkFormField.tsx' import MarkFormField from '../MarkFormField'
import { PdfFile } from '../../types/drawing.ts'
import { CurrentUserMark, Mark } from '../../types/mark.ts' import { CurrentUserMark, Mark } from '../../types/mark.ts'
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { import {
findNextCurrentUserMark, findNextIncompleteCurrentUserMark,
isCurrentUserMarksComplete, getUpdatedMark,
updateCurrentUserMarks updateCurrentUserMarks
} from '../../utils' } from '../../utils'
import { EMPTY } from '../../utils/const.ts' import { EMPTY } from '../../utils/const.ts'
import { Container } from '../Container' import { Container } from '../Container'
import styles from '../../pages/sign/style.module.scss' import signPageStyles from '../../pages/sign/style.module.scss'
import styles from './style.module.scss'
import { CurrentUserFile } from '../../types/file.ts'
import FileList from '../FileList'
import { StickySideColumns } from '../../layouts/StickySideColumns.tsx'
import { UsersDetails } from '../UsersDetails.tsx'
import { Meta } from '../../types'
interface PdfMarkingProps { interface PdfMarkingProps {
files: { pdfFile: PdfFile; filename: string; hash: string | null }[] files: CurrentUserFile[]
currentUserMarks: CurrentUserMark[] currentUserMarks: CurrentUserMark[]
setIsReadyToSign: (isReadyToSign: boolean) => void setIsReadyToSign: (isReadyToSign: boolean) => void
setCurrentUserMarks: (currentUserMarks: CurrentUserMark[]) => void setCurrentUserMarks: (currentUserMarks: CurrentUserMark[]) => void
setUpdatedMarks: (markToUpdate: Mark) => void setUpdatedMarks: (markToUpdate: Mark) => void
handleDownload: () => void
meta: Meta | null
} }
/** /**
@ -32,14 +39,28 @@ const PdfMarking = (props: PdfMarkingProps) => {
currentUserMarks, currentUserMarks,
setIsReadyToSign, setIsReadyToSign,
setCurrentUserMarks, setCurrentUserMarks,
setUpdatedMarks setUpdatedMarks,
handleDownload,
meta
} = props } = props
const [selectedMark, setSelectedMark] = useState<CurrentUserMark | null>(null) const [selectedMark, setSelectedMark] = useState<CurrentUserMark | null>(null)
const [selectedMarkValue, setSelectedMarkValue] = useState<string>('') const [selectedMarkValue, setSelectedMarkValue] = useState<string>('')
const [currentFile, setCurrentFile] = useState<CurrentUserFile | null>(null)
useEffect(() => { useEffect(() => {
setSelectedMark(findNextCurrentUserMark(currentUserMarks) || null) if (selectedMark === null && currentUserMarks.length > 0) {
}, [currentUserMarks]) setSelectedMark(
findNextIncompleteCurrentUserMark(currentUserMarks) ||
currentUserMarks[0]
)
}
}, [currentUserMarks, selectedMark])
useEffect(() => {
if (currentFile === null && files.length > 0) {
setCurrentFile(files[0])
}
}, [files, currentFile])
const handleMarkClick = (id: number) => { const handleMarkClick = (id: number) => {
const nextMark = currentUserMarks.find((mark) => mark.mark.id === id) const nextMark = currentUserMarks.find((mark) => mark.mark.id === id)
@ -47,18 +68,30 @@ const PdfMarking = (props: PdfMarkingProps) => {
setSelectedMarkValue(nextMark?.mark.value ?? EMPTY) setSelectedMarkValue(nextMark?.mark.value ?? EMPTY)
} }
const handleCurrentUserMarkChange = (mark: CurrentUserMark) => {
if (!selectedMark) return
const updatedSelectedMark: CurrentUserMark = getUpdatedMark(
selectedMark,
selectedMarkValue
)
const updatedCurrentUserMarks = updateCurrentUserMarks(
currentUserMarks,
updatedSelectedMark
)
setCurrentUserMarks(updatedCurrentUserMarks)
setSelectedMarkValue(mark.currentValue ?? EMPTY)
setSelectedMark(mark)
}
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => { const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault() event.preventDefault()
if (!selectedMarkValue || !selectedMark) return if (!selectedMarkValue || !selectedMark) return
const updatedMark: CurrentUserMark = { const updatedMark: CurrentUserMark = getUpdatedMark(
...selectedMark, selectedMark,
mark: { selectedMarkValue
...selectedMark.mark, )
value: selectedMarkValue
},
isCompleted: true
}
setSelectedMarkValue(EMPTY) setSelectedMarkValue(EMPTY)
const updatedCurrentUserMarks = updateCurrentUserMarks( const updatedCurrentUserMarks = updateCurrentUserMarks(
@ -66,33 +99,62 @@ const PdfMarking = (props: PdfMarkingProps) => {
updatedMark updatedMark
) )
setCurrentUserMarks(updatedCurrentUserMarks) setCurrentUserMarks(updatedCurrentUserMarks)
setSelectedMark(findNextCurrentUserMark(updatedCurrentUserMarks) || null) setSelectedMark(null)
console.log(isCurrentUserMarksComplete(updatedCurrentUserMarks)) setIsReadyToSign(true)
setIsReadyToSign(isCurrentUserMarksComplete(updatedCurrentUserMarks))
setUpdatedMarks(updatedMark.mark) setUpdatedMarks(updatedMark.mark)
} }
// const updateCurrentUserMarkValues = () => {
// const updatedMark: CurrentUserMark = getUpdatedMark(selectedMark!, selectedMarkValue)
// const updatedCurrentUserMarks = updateCurrentUserMarks(currentUserMarks, updatedMark)
// setSelectedMarkValue(EMPTY)
// setCurrentUserMarks(updatedCurrentUserMarks)
// }
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => const handleChange = (event: React.ChangeEvent<HTMLInputElement>) =>
setSelectedMarkValue(event.target.value) setSelectedMarkValue(event.target.value)
return ( return (
<> <>
<Container className={styles.container}> <Container className={signPageStyles.container}>
{currentUserMarks?.length > 0 && ( <StickySideColumns
<PdfView left={
files={files} <div>
handleMarkClick={handleMarkClick} {currentFile !== null && (
selectedMarkValue={selectedMarkValue} <FileList
selectedMark={selectedMark} files={files}
currentUserMarks={currentUserMarks} currentFile={currentFile}
/> setCurrentFile={setCurrentFile}
)} handleDownload={handleDownload}
/>
)}
</div>
}
right={meta !== null && <UsersDetails meta={meta} />}
>
<div className={styles.container}>
{currentUserMarks?.length > 0 && (
<div className={styles.pdfView}>
<PdfView
currentFile={currentFile}
files={files}
handleMarkClick={handleMarkClick}
selectedMarkValue={selectedMarkValue}
selectedMark={selectedMark}
currentUserMarks={currentUserMarks}
/>
</div>
)}
</div>
</StickySideColumns>
{selectedMark !== null && ( {selectedMark !== null && (
<MarkFormField <MarkFormField
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
handleChange={handleChange} handleSelectedMarkValueChange={handleChange}
selectedMark={selectedMark} selectedMark={selectedMark}
selectedMarkValue={selectedMarkValue} selectedMarkValue={selectedMarkValue}
currentUserMarks={currentUserMarks}
handleCurrentUserMarkChange={handleCurrentUserMarkChange}
/> />
)} )}
</Container> </Container>

View File

@ -2,6 +2,7 @@ import styles from '../DrawPDFFields/style.module.scss'
import { PdfPage } from '../../types/drawing.ts' import { PdfPage } from '../../types/drawing.ts'
import { CurrentUserMark } from '../../types/mark.ts' import { CurrentUserMark } from '../../types/mark.ts'
import PdfMarkItem from './PdfMarkItem.tsx' import PdfMarkItem from './PdfMarkItem.tsx'
import { useEffect, useRef } from 'react'
interface PdfPageProps { interface PdfPageProps {
page: PdfPage page: PdfPage
currentUserMarks: CurrentUserMark[] currentUserMarks: CurrentUserMark[]
@ -20,24 +21,33 @@ const PdfPageItem = ({
selectedMarkValue, selectedMarkValue,
selectedMark selectedMark
}: PdfPageProps) => { }: PdfPageProps) => {
useEffect(() => {
if (selectedMark !== null && !!markRefs.current[selectedMark.id]) {
markRefs.current[selectedMark.id]?.scrollIntoView({
behavior: 'smooth',
block: 'end'
})
}
}, [selectedMark])
const markRefs = useRef<(HTMLDivElement | null)[]>([])
return ( return (
<div <div
className={styles.pdfImageWrapper} className={styles.pdfImageWrapper}
style={{ style={{
border: '1px solid #c4c4c4', border: '1px solid #c4c4c4'
marginBottom: '10px',
marginTop: '10px'
}} }}
> >
<img draggable="false" src={page.image} style={{ width: '100%' }} /> <img draggable="false" src={page.image} style={{ width: '100%' }} />
{currentUserMarks.map((m, i) => ( {currentUserMarks.map((m, i) => (
<PdfMarkItem <div key={i} ref={(el) => (markRefs.current[m.id] = el)}>
key={i} <PdfMarkItem
handleMarkClick={handleMarkClick} key={i}
selectedMarkValue={selectedMarkValue} handleMarkClick={handleMarkClick}
userMark={m} selectedMarkValue={selectedMarkValue}
selectedMark={selectedMark} userMark={m}
/> selectedMark={selectedMark}
/>
</div>
))} ))}
</div> </div>
) )

View File

@ -1,14 +1,16 @@
import { PdfFile } from '../../types/drawing.ts' import { Divider } from '@mui/material'
import { Box } from '@mui/material'
import PdfItem from './PdfItem.tsx' import PdfItem from './PdfItem.tsx'
import { CurrentUserMark } from '../../types/mark.ts' import { CurrentUserMark } from '../../types/mark.ts'
import { CurrentUserFile } from '../../types/file.ts'
import { useEffect, useRef } from 'react'
interface PdfViewProps { interface PdfViewProps {
files: { pdfFile: PdfFile; filename: string; hash: string | null }[] files: CurrentUserFile[]
currentUserMarks: CurrentUserMark[] currentUserMarks: CurrentUserMark[]
handleMarkClick: (id: number) => void handleMarkClick: (id: number) => void
selectedMarkValue: string selectedMarkValue: string
selectedMark: CurrentUserMark | null selectedMark: CurrentUserMark | null
currentFile: CurrentUserFile | null
} }
/** /**
@ -19,8 +21,18 @@ const PdfView = ({
currentUserMarks, currentUserMarks,
handleMarkClick, handleMarkClick,
selectedMarkValue, selectedMarkValue,
selectedMark selectedMark,
currentFile
}: PdfViewProps) => { }: PdfViewProps) => {
const pdfRefs = useRef<(HTMLDivElement | null)[]>([])
useEffect(() => {
if (currentFile !== null && !!pdfRefs.current[currentFile.id]) {
pdfRefs.current[currentFile.id]?.scrollIntoView({
behavior: 'smooth',
block: 'end'
})
}
}, [currentFile])
const filterByFile = ( const filterByFile = (
currentUserMarks: CurrentUserMark[], currentUserMarks: CurrentUserMark[],
hash: string hash: string
@ -29,22 +41,31 @@ const PdfView = ({
(currentUserMark) => currentUserMark.mark.pdfFileHash === hash (currentUserMark) => currentUserMark.mark.pdfFileHash === hash
) )
} }
const isNotLastPdfFile = (index: number, files: CurrentUserFile[]): boolean =>
index !== files.length - 1
return ( return (
<Box sx={{ width: '100%' }}> <>
{files.map(({ pdfFile, hash }, i) => { {files.map((currentUserFile, index, arr) => {
const { hash, pdfFile, id } = currentUserFile
if (!hash) return if (!hash) return
return ( return (
<PdfItem <div
pdfFile={pdfFile} id={pdfFile.file.name}
key={i} ref={(el) => (pdfRefs.current[id] = el)}
currentUserMarks={filterByFile(currentUserMarks, hash)} key={index}
selectedMark={selectedMark} >
handleMarkClick={handleMarkClick} <PdfItem
selectedMarkValue={selectedMarkValue} pdfFile={pdfFile}
/> currentUserMarks={filterByFile(currentUserMarks, hash)}
selectedMark={selectedMark}
handleMarkClick={handleMarkClick}
selectedMarkValue={selectedMarkValue}
/>
{isNotLastPdfFile(index, arr) && <Divider>File Separator</Divider>}
</div>
) )
})} })}
</Box> </>
) )
} }

View File

@ -14,3 +14,18 @@
max-height: 100%; max-height: 100%;
object-fit: contain; /* Ensure the image fits within the container */ object-fit: contain; /* Ensure the image fits within the container */
} }
.container {
display: flex;
width: 100%;
flex-direction: column;
}
.pdfView {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
gap: 10px;
}

View File

@ -0,0 +1,6 @@
import { PropsWithChildren } from 'react'
import styles from './style.module.scss'
export const Spinner = ({ children }: PropsWithChildren) => (
<div className={styles.spin}>{children}</div>
)

View File

@ -0,0 +1,12 @@
.spin {
animation: spin 5s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -30,7 +30,7 @@ export const UserAvatar = ({ pubkey, name, image }: UserAvatarProps) => {
padding: 0 padding: 0
}} }}
/> />
{name ? <label className={styles.username}>{name}</label> : null} {name ? <span className={styles.username}>{name}</span> : null}
</Link> </Link>
) )
} }

View File

@ -10,4 +10,8 @@
font-weight: 500; font-weight: 500;
font-size: 14px; font-size: 14px;
color: var(--text-color); color: var(--text-color);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
} }

View File

@ -28,7 +28,7 @@ export const UserAvatarGroup = ({
const childrenArray = Children.toArray(children) const childrenArray = Children.toArray(children)
return ( return (
<div {...rest}> <div className={styles.container} {...rest}>
{surplus > 1 {surplus > 1
? childrenArray.slice(0, surplus * -1).map((c) => c) ? childrenArray.slice(0, surplus * -1).map((c) => c)
: children} : children}

View File

@ -1,5 +1,25 @@
@import '../../styles/colors.scss'; @import '../../styles/colors.scss';
.container {
padding: 0 0 0 10px;
> * {
transition: margin ease 0.2s;
margin: 0 0 0 -10px;
position: relative;
z-index: 1;
&:first-child {
margin-left: -10px !important;
}
}
> *:hover,
> *:focus-within {
margin: 0 15px 0 5px;
z-index: 2;
}
}
.icon { .icon {
width: 40px; width: 40px;
height: 40px; height: 40px;

View File

@ -0,0 +1,222 @@
import { Divider, Tooltip } from '@mui/material'
import { useSigitProfiles } from '../../hooks/useSigitProfiles'
import {
extractFileExtensions,
formatTimestamp,
fromUnixTimestamp,
hexToNpub,
npubToHex,
shorten,
SignStatus
} from '../../utils'
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'
import { TooltipChild } from '../TooltipChild'
import { DisplaySigner } from '../DisplaySigner'
import { Meta } from '../../types'
interface UsersDetailsProps {
meta: Meta
}
export const UsersDetails = ({ meta }: UsersDetailsProps) => {
const {
submittedBy,
signers,
viewers,
fileHashes,
signersStatus,
createdAt,
completedAt,
parsedSignatureEvents,
signedStatus,
isValid
} = useSigitMeta(meta)
const { usersPubkey } = useSelector((state: State) => state.auth)
const profiles = useSigitProfiles([
...(submittedBy ? [submittedBy] : []),
...signers,
...viewers
])
const userCanSign =
typeof usersPubkey !== 'undefined' &&
signers.includes(hexToNpub(usersPubkey))
const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes))
return submittedBy ? (
<div className={styles.container}>
<div className={styles.section}>
<p>Signers</p>
<div className={styles.users}>
{submittedBy &&
(function () {
const profile = profiles[submittedBy]
return (
<Tooltip
key={submittedBy}
title={
profile?.display_name ||
profile?.name ||
shorten(hexToNpub(submittedBy))
}
placement="top"
arrow
disableInteractive
>
<TooltipChild>
<DisplaySigner
status={isValid ? SignStatus.Signed : SignStatus.Invalid}
profile={profile}
pubkey={submittedBy}
/>
</TooltipChild>
</Tooltip>
)
})()}
{submittedBy && signers.length ? (
<Divider orientation="vertical" flexItem />
) : null}
<UserAvatarGroup max={20}>
{signers.map((signer) => {
const pubkey = npubToHex(signer)!
const profile = profiles[pubkey]
return (
<Tooltip
key={signer}
title={
profile?.display_name || profile?.name || shorten(pubkey)
}
placement="top"
arrow
disableInteractive
>
<TooltipChild>
<DisplaySigner
status={signersStatus[signer]}
profile={profile}
pubkey={pubkey}
/>
</TooltipChild>
</Tooltip>
)
})}
{viewers.map((signer) => {
const pubkey = npubToHex(signer)!
const profile = profiles[pubkey]
return (
<Tooltip
key={signer}
title={
profile?.display_name || profile?.name || shorten(pubkey)
}
placement="top"
arrow
disableInteractive
>
<TooltipChild>
<DisplaySigner
status={SignStatus.Viewer}
profile={profile}
pubkey={pubkey}
/>
</TooltipChild>
</Tooltip>
)
})}
</UserAvatarGroup>
</div>
</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>
{extensions.length > 0 ? (
<span className={styles.detailsItem}>
{!isSame ? (
<>
<FontAwesomeIcon icon={faFile} /> Multiple File Types
</>
) : (
getExtensionIconLabel(extensions[0])
)}
</span>
) : (
<>
<FontAwesomeIcon icon={faFileCircleExclamation} /> &mdash;
</>
)}
</div>
</div>
) : undefined
}

View File

@ -0,0 +1,46 @@
@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;
}
.users {
display: flex;
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

@ -1,22 +1,23 @@
import { EventTemplate } from 'nostr-tools' import { EventTemplate } from 'nostr-tools'
import { MetadataController, NostrController } from '.' import { MetadataController, NostrController } from '.'
import { appPrivateRoutes } from '../routes'
import { import {
setAuthState, setAuthState,
setMetadataEvent, setMetadataEvent,
setRelayMapAction setRelayMapAction
} from '../store/actions' } from '../store/actions'
import store from '../store/store' import store from '../store/store'
import { SignedEvent } from '../types'
import { import {
base64DecodeAuthToken, base64DecodeAuthToken,
base64EncodeSignedEvent, base64EncodeSignedEvent,
compareObjects,
getAuthToken, getAuthToken,
getRelayMap,
getVisitedLink, getVisitedLink,
saveAuthToken, saveAuthToken,
compareObjects,
unixNow unixNow
} from '../utils' } from '../utils'
import { appPrivateRoutes } from '../routes'
import { SignedEvent } from '../types'
export class AuthController { export class AuthController {
private nostrController: NostrController private nostrController: NostrController
@ -75,7 +76,7 @@ export class AuthController {
}) })
) )
const relayMap = await this.nostrController.getRelayMap(pubkey) const relayMap = await getRelayMap(pubkey)
if (Object.keys(relayMap).length < 1) { if (Object.keys(relayMap).length < 1) {
// Navigate user to relays page if relay map is empty // Navigate user to relays page if relay map is empty

View File

@ -1,28 +1,25 @@
import { import {
Event,
Filter, Filter,
SimplePool,
VerifiedEvent, VerifiedEvent,
kinds, kinds,
validateEvent, validateEvent,
verifyEvent, verifyEvent
Event,
EventTemplate,
nip19
} from 'nostr-tools' } from 'nostr-tools'
import { NostrJoiningBlock, ProfileMetadata, RelaySet } from '../types'
import { NostrController } from '.'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { queryNip05, unixNow } from '../utils'
import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'
import { EventEmitter } from 'tseep' import { EventEmitter } from 'tseep'
import { NostrController, relayController } from '.'
import { localCache } from '../services' import { localCache } from '../services'
import { ProfileMetadata, RelaySet } from '../types'
import { import {
findRelayListAndUpdateCache, findRelayListAndUpdateCache,
findRelayListInCache, findRelayListInCache,
getDefaultRelaySet, getDefaultRelaySet,
getMostPopularRelays,
getUserRelaySet, getUserRelaySet,
isOlderThanOneWeek isOlderThanOneWeek,
} from '../utils/relays.ts' unixNow
} from '../utils'
export class MetadataController extends EventEmitter { export class MetadataController extends EventEmitter {
private nostrController: NostrController private nostrController: NostrController
@ -51,11 +48,9 @@ export class MetadataController extends EventEmitter {
authors: [hexKey] // Authored by the specified key authors: [hexKey] // Authored by the specified key
} }
const pool = new SimplePool()
// Try to get the metadata event from a special relay (wss://purplepag.es) // Try to get the metadata event from a special relay (wss://purplepag.es)
const metadataEvent = await pool const metadataEvent = await relayController
.get([this.specialMetadataRelay], eventFilter) .fetchEvent(eventFilter, [this.specialMetadataRelay])
.catch((err) => { .catch((err) => {
console.error(err) // Log any errors console.error(err) // Log any errors
return null // Return null if an error occurs return null // Return null if an error occurs
@ -80,11 +75,12 @@ export class MetadataController extends EventEmitter {
} }
// If no valid metadata event is found from the special relay, get the most popular relays // If no valid metadata event is found from the special relay, get the most popular relays
const mostPopularRelays = await this.nostrController.getMostPopularRelays() const mostPopularRelays = await getMostPopularRelays()
// Query the most popular relays for metadata events // Query the most popular relays for metadata events
const events = await pool
.querySync(mostPopularRelays, eventFilter) const events = await relayController
.fetchEvents(eventFilter, mostPopularRelays)
.catch((err) => { .catch((err) => {
console.error(err) // Log any errors console.error(err) // Log any errors
return null // Return null if an error occurs return null // Return null if an error occurs
@ -169,10 +165,7 @@ export class MetadataController extends EventEmitter {
[this.specialMetadataRelay], [this.specialMetadataRelay],
hexKey hexKey
)) || )) ||
(await findRelayListAndUpdateCache( (await findRelayListAndUpdateCache(await getMostPopularRelays(), hexKey))
await this.nostrController.getMostPopularRelays(),
hexKey
))
return relayEvent ? getUserRelaySet(relayEvent.tags) : getDefaultRelaySet() return relayEvent ? getUserRelaySet(relayEvent.tags) : getDefaultRelaySet()
} }
@ -206,143 +199,21 @@ export class MetadataController extends EventEmitter {
await this.nostrController.signEvent(newMetadataEvent) await this.nostrController.signEvent(newMetadataEvent)
} }
await this.nostrController await relayController
.publishEvent(signedMetadataEvent, [this.specialMetadataRelay]) .publish(signedMetadataEvent, [this.specialMetadataRelay])
.then((relays) => { .then((relays) => {
toast.success(`Metadata event published on: ${relays.join('\n')}`) if (relays.length) {
this.handleNewMetadataEvent(signedMetadataEvent as VerifiedEvent) toast.success(`Metadata event published on: ${relays.join('\n')}`)
this.handleNewMetadataEvent(signedMetadataEvent as VerifiedEvent)
} else {
toast.error('Could not publish metadata event to any relay!')
}
}) })
.catch((err) => { .catch((err) => {
toast.error(err.message) toast.error(err.message)
}) })
} }
public getNostrJoiningBlockNumber = async (
hexKey: string
): Promise<NostrJoiningBlock | null> => {
const relaySet = await this.findRelayListMetadata(hexKey)
const userRelays: string[] = []
// find user's relays
if (relaySet.write.length > 0) {
userRelays.push(...relaySet.write)
} else {
const metadata = await this.findMetadata(hexKey)
if (!metadata) return null
const metadataContent = this.extractProfileMetadataContent(metadata)
if (metadataContent?.nip05) {
const nip05Profile = await queryNip05(metadataContent.nip05)
if (nip05Profile && nip05Profile.pubkey === hexKey) {
userRelays.push(...nip05Profile.relays)
}
}
}
if (userRelays.length === 0) return null
// filter for finding user's first kind 0 event
const eventFilter: Filter = {
kinds: [kinds.Metadata],
authors: [hexKey]
}
const pool = new SimplePool()
// 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
events.sort((a, b) => a.created_at - b.created_at)
// get first ever event published on user's relays
const event = events[0]
const { created_at } = event
// initialize job request
const jobEventTemplate: EventTemplate = {
content: '',
created_at: unixNow(),
kind: 68001,
tags: [
['i', `${created_at * 1000}`],
['j', 'blockChain-block-number']
]
}
// sign job request event
const jobSignedEvent =
await this.nostrController.signEvent(jobEventTemplate)
const relays = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://relayable.org'
]
// publish job request
await this.nostrController.publishEvent(jobSignedEvent, relays)
console.log('jobSignedEvent :>> ', jobSignedEvent)
const subscribeWithTimeout = (
subscription: NDKSubscription,
timeoutMs: number
): Promise<string> => {
return new Promise((resolve, reject) => {
const eventHandler = (event: NDKEvent) => {
subscription.stop()
resolve(event.content)
}
subscription.on('event', eventHandler)
// Set up a timeout to stop the subscription after a specified time
const timeout = setTimeout(() => {
subscription.stop() // Stop the subscription
reject(new Error('Subscription timed out')) // Reject the promise with a timeout error
}, timeoutMs)
// Handle subscription close event
subscription.on('close', () => clearTimeout(timeout))
})
}
const dvmNDK = new NDK({
explicitRelayUrls: relays
})
await dvmNDK.connect(2000)
// filter for getting DVM job's result
const sub = dvmNDK.subscribe({
kinds: [68002 as number],
'#e': [jobSignedEvent.id],
'#p': [jobSignedEvent.pubkey]
})
// asynchronously get block number from dvm job with 20 seconds timeout
const dvmJobResult = await subscribeWithTimeout(sub, 20000)
const encodedEventPointer = nip19.neventEncode({
id: event.id,
relays: userRelays,
author: event.pubkey,
kind: event.kind
})
return {
block: parseInt(dvmJobResult),
encodedEventPointer
}
}
return null
}
public validate = (event: Event) => validateEvent(event) && verifyEvent(event) public validate = (event: Event) => validateEvent(event) && verifyEvent(event)
public getEmptyMetadataEvent = (): Event => { public getEmptyMetadataEvent = (): Event => {

View File

@ -2,50 +2,24 @@ import NDK, {
NDKEvent, NDKEvent,
NDKNip46Signer, NDKNip46Signer,
NDKPrivateKeySigner, NDKPrivateKeySigner,
NDKSubscription,
NDKUser, NDKUser,
NostrEvent NostrEvent
} from '@nostr-dev-kit/ndk' } from '@nostr-dev-kit/ndk'
import axios from 'axios'
import { import {
Event, Event,
EventTemplate, EventTemplate,
Filter,
Relay,
SimplePool,
UnsignedEvent, UnsignedEvent,
finalizeEvent, finalizeEvent,
kinds,
nip04, nip04,
nip19, nip19,
nip44 nip44
} from 'nostr-tools' } from 'nostr-tools'
import { toast } from 'react-toastify'
import { EventEmitter } from 'tseep' import { EventEmitter } from 'tseep'
import { import { updateNsecbunkerPubkey } from '../store/actions'
setMostPopularRelaysAction,
setRelayConnectionStatusAction,
setRelayInfoAction,
updateNsecbunkerPubkey
} from '../store/actions'
import { AuthState, LoginMethods } from '../store/auth/types' import { AuthState, LoginMethods } from '../store/auth/types'
import store from '../store/store' import store from '../store/store'
import { import { SignedEvent } from '../types'
RelayConnectionState, import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils'
RelayConnectionStatus,
RelayInfoObject,
RelayMap,
RelayReadStats,
RelayStats,
SignedEvent
} from '../types'
import {
compareObjects,
getNsecBunkerDelegatedKey,
unixNow,
verifySignedEvent
} from '../utils'
import { getDefaultRelayMap } from '../utils/relays.ts'
export class NostrController extends EventEmitter { export class NostrController extends EventEmitter {
private static instance: NostrController private static instance: NostrController
@ -53,14 +27,13 @@ export class NostrController extends EventEmitter {
private bunkerNDK: NDK | undefined private bunkerNDK: NDK | undefined
private remoteSigner: NDKNip46Signer | undefined private remoteSigner: NDKNip46Signer | undefined
private connectedRelays: Relay[] | undefined
private constructor() { private constructor() {
super() super()
} }
private getNostrObject = () => { private getNostrObject = () => {
// fix: this is not picking up type declaration from src/system/index.d.ts // fix: this is not picking up type declaration from src/system/index.d.ts
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (window.nostr) return window.nostr as any if (window.nostr) return window.nostr as any
throw new Error( throw new Error(
@ -223,98 +196,6 @@ export class NostrController extends EventEmitter {
return NostrController.instance return NostrController.instance
} }
/**
* Function will publish provided event to the provided relays
*
* @param event - The event to publish.
* @param relays - An array of relay URLs to publish the event to.
* @returns A promise that resolves to an array of relays where the event was successfully published.
*/
publishEvent = async (event: Event, relays: string[]) => {
const simplePool = new SimplePool()
// Publish the event to all relays
const promises = simplePool.publish(relays, event)
// Use Promise.race to wait for the first successful publish
const firstSuccessfulPublish = await Promise.race(
promises.map((promise, index) =>
promise.then(() => relays[index]).catch(() => null)
)
)
if (!firstSuccessfulPublish) {
// If no publish was successful, collect the reasons for failures
const failedPublishes: unknown[] = []
const fallbackRejectionReason =
'Attempt to publish an event has been rejected with unknown reason.'
const results = await Promise.allSettled(promises)
results.forEach((res, index) => {
if (res.status === 'rejected') {
failedPublishes.push({
relay: relays[index],
error: res.reason
? res.reason.message || fallbackRejectionReason
: fallbackRejectionReason
})
}
})
throw failedPublishes
}
// Continue publishing to other relays in the background
promises.forEach((promise, index) => {
promise.catch((err) => {
console.log(`Failed to publish to ${relays[index]}`, err)
})
})
return [firstSuccessfulPublish]
}
/**
* Asynchronously retrieves an event from a set of relays based on a provided filter.
* If no relays are specified, it defaults to using connected relays.
*
* @param {Filter} filter - The filter criteria to find the event.
* @param {string[]} [relays] - An optional array of relay URLs to search for the event.
* @returns {Promise<Event | null>} - Returns a promise that resolves to the found event or null if not found.
*/
getEvent = async (
filter: Filter,
relays?: string[]
): Promise<Event | null> => {
// If no relays are provided or the provided array is empty, use connected relays if available.
if (!relays || relays.length === 0) {
relays = this.connectedRelays
? this.connectedRelays.map((relay) => relay.url)
: []
}
// If still no relays are available, reject the promise with an error message.
if (relays.length === 0) {
return Promise.reject('Provide some relays to find the event')
}
// Create a new instance of SimplePool to handle the relay connections and event retrieval.
const pool = new SimplePool()
// Attempt to retrieve the event from the specified relays using the filter criteria.
const event = await pool.get(relays, filter).catch((err) => {
// Log any errors that occur during the event retrieval process.
console.log('An error occurred in finding the event', err)
// Show an error toast notification to the user.
toast.error('An error occurred in finding the event')
// Return null if an error occurs, indicating that no event was found.
return null
})
// Return the found event, or null if an error occurred.
return event
}
/** /**
* Encrypts the given content for the specified receiver using NIP-44 encryption. * Encrypts the given content for the specified receiver using NIP-44 encryption.
* *
@ -650,359 +531,4 @@ export class NostrController extends EventEmitter {
generateDelegatedKey = (): string => { generateDelegatedKey = (): string => {
return NDKPrivateKeySigner.generate().privateKey! return NDKPrivateKeySigner.generate().privateKey!
} }
/**
* Provides relay map.
* @param npub - user's npub
* @returns - promise that resolves into relay map and a timestamp when it has been updated.
*/
getRelayMap = async (
npub: string
): Promise<{ map: RelayMap; mapUpdated?: number }> => {
const mostPopularRelays = await this.getMostPopularRelays()
const pool = new SimplePool()
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
const eventFilter: Filter = {
kinds: [kinds.RelayList],
authors: [npub]
}
const event = await pool
.get(mostPopularRelays, eventFilter)
.catch((err) => {
return Promise.reject(err)
})
if (event) {
// Handle founded 10002 event
const relaysMap: RelayMap = {}
// 'r' stands for 'relay'
const relayTags = event.tags.filter((tag) => tag[0] === 'r')
relayTags.forEach((tag) => {
const uri = tag[1]
const relayType = tag[2]
// if 3rd element of relay tag is undefined, relay is WRITE and READ
relaysMap[uri] = {
write: relayType ? relayType === 'write' : true,
read: relayType ? relayType === 'read' : true
}
})
this.getRelayInfo(Object.keys(relaysMap))
this.connectToRelays(Object.keys(relaysMap))
return Promise.resolve({ map: relaysMap, mapUpdated: event.created_at })
} else {
return Promise.resolve({ map: getDefaultRelayMap() })
}
}
/**
* Publishes relay map.
* @param relayMap - relay map.
* @param npub - user's npub.
* @param extraRelaysToPublish - optional relays to publish relay map.
* @returns - promise that resolves into a string representing publishing result.
*/
publishRelayMap = async (
relayMap: RelayMap,
npub: string,
extraRelaysToPublish?: string[]
): Promise<string> => {
const timestamp = unixNow()
const relayURIs = Object.keys(relayMap)
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
const tags: string[][] = relayURIs.map((relayURI) =>
[
'r',
relayURI,
relayMap[relayURI].read && relayMap[relayURI].write
? ''
: relayMap[relayURI].write
? 'write'
: 'read'
].filter((value) => value !== '')
)
const newRelayMapEvent: UnsignedEvent = {
kind: kinds.RelayList,
tags,
content: '',
pubkey: npub,
created_at: timestamp
}
const signedEvent = await this.signEvent(newRelayMapEvent)
let relaysToPublish = relayURIs
// Add extra relays if provided
if (extraRelaysToPublish) {
relaysToPublish = [...relaysToPublish, ...extraRelaysToPublish]
}
// If relay map is empty, use most popular relay URIs
if (!relaysToPublish.length) {
relaysToPublish = await this.getMostPopularRelays()
}
const publishResult = await this.publishEvent(signedEvent, relaysToPublish)
if (publishResult && publishResult.length) {
return Promise.resolve(
`Relay Map published on: ${publishResult.join('\n')}`
)
}
return Promise.reject('Publishing updated relay map was unsuccessful.')
}
/**
* Provides most popular relays.
* @param numberOfTopRelays - number representing how many most popular relays to provide
* @returns - promise that resolves into an array of most popular relays
*/
getMostPopularRelays = async (
numberOfTopRelays: number = 30
): Promise<string[]> => {
const mostPopularRelaysState = store.getState().relays?.mostPopular
// return most popular relays from app state if present
if (mostPopularRelaysState) return mostPopularRelaysState
// relays in env
const { VITE_MOST_POPULAR_RELAYS } = import.meta.env
const hardcodedPopularRelays = (VITE_MOST_POPULAR_RELAYS || '').split(' ')
const url = `https://stats.nostr.band/stats_api?method=stats`
const response = await axios.get<RelayStats>(url).catch(() => undefined)
if (!response) {
return hardcodedPopularRelays //return hardcoded relay list
}
const data = response.data
if (!data) {
return hardcodedPopularRelays //return hardcoded relay list
}
const apiTopRelays = data.relay_stats.user_picks.read_relays
.slice(0, numberOfTopRelays)
.map((relay: RelayReadStats) => relay.d)
if (!apiTopRelays.length) {
return Promise.reject(`Couldn't fetch popular relays.`)
}
if (store.getState().auth?.loggedIn) {
store.dispatch(setMostPopularRelaysAction(apiTopRelays))
}
return apiTopRelays
}
/**
* Sets information about relays into relays.info app state.
* @param relayURIs - relay URIs to get information about
*/
getRelayInfo = async (relayURIs: string[]) => {
// initialize job request
const jobEventTemplate: EventTemplate = {
content: '',
created_at: unixNow(),
kind: 68001,
tags: [
['i', `${JSON.stringify(relayURIs)}`],
['j', 'relay-info']
]
}
// sign job request event
const jobSignedEvent = await this.signEvent(jobEventTemplate)
const relays = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://relayable.org'
]
// publish job request
await this.publishEvent(jobSignedEvent, relays)
console.log('jobSignedEvent :>> ', jobSignedEvent)
const subscribeWithTimeout = (
subscription: NDKSubscription,
timeoutMs: number
): Promise<string> => {
return new Promise((resolve, reject) => {
const eventHandler = (event: NDKEvent) => {
subscription.stop()
resolve(event.content)
}
subscription.on('event', eventHandler)
// Set up a timeout to stop the subscription after a specified time
const timeout = setTimeout(() => {
subscription.stop() // Stop the subscription
reject(new Error('Subscription timed out')) // Reject the promise with a timeout error
}, timeoutMs)
// Handle subscription close event
subscription.on('close', () => clearTimeout(timeout))
})
}
const dvmNDK = new NDK({
explicitRelayUrls: relays
})
await dvmNDK.connect(2000)
// filter for getting DVM job's result
const sub = dvmNDK.subscribe({
kinds: [68002 as number],
'#e': [jobSignedEvent.id],
'#p': [jobSignedEvent.pubkey]
})
// asynchronously get block number from dvm job with 20 seconds timeout
const dvmJobResult = await subscribeWithTimeout(sub, 20000)
if (!dvmJobResult) {
return Promise.reject(`Relay(s) information wasn't received`)
}
let relaysInfo: RelayInfoObject
try {
relaysInfo = JSON.parse(dvmJobResult)
} catch (error) {
return Promise.reject(`Invalid relay(s) information.`)
}
if (
relaysInfo &&
!compareObjects(store.getState().relays?.info, relaysInfo)
) {
store.dispatch(setRelayInfoAction(relaysInfo))
}
}
/**
* Establishes connection to relays.
* @param relayURIs - an array of relay URIs
* @returns - promise that resolves into an array of connections
*/
connectToRelays = async (relayURIs: string[]) => {
// Copy of relay connection status
const relayConnectionsStatus: RelayConnectionStatus = JSON.parse(
JSON.stringify(store.getState().relays?.connectionStatus || {})
)
const connectedRelayURLs = this.connectedRelays
? this.connectedRelays.map((relay) => relay.url)
: []
// Check if connections already established
if (compareObjects(connectedRelayURLs, relayURIs)) {
return
}
const connections = relayURIs
.filter((relayURI) => !connectedRelayURLs.includes(relayURI))
.map((relayURI) =>
Relay.connect(relayURI)
.then((relay) => {
// put connection status into relayConnectionsStatus object
relayConnectionsStatus[relayURI] = relay.connected
? RelayConnectionState.Connected
: RelayConnectionState.NotConnected
return relay
})
.catch(() => {
relayConnectionsStatus[relayURI] = RelayConnectionState.NotConnected
})
)
const connected = await Promise.all(connections)
// put connected relays into connectedRelays private property, so it can be closed later
this.connectedRelays = connected.filter(
(relay) => relay instanceof Relay && relay.connected
) as Relay[]
if (Object.keys(relayConnectionsStatus)) {
if (
!compareObjects(
store.getState().relays?.connectionStatus,
relayConnectionsStatus
)
) {
store.dispatch(setRelayConnectionStatusAction(relayConnectionsStatus))
}
}
return Promise.resolve(relayConnectionsStatus)
}
/**
* Disconnects from relays.
* @param relayURIs - array of relay URIs to disconnect from
*/
disconnectFromRelays = async (relayURIs: string[]) => {
const connectedRelayURLs = this.connectedRelays
? this.connectedRelays.map((relay) => relay.url)
: []
relayURIs
.filter((relayURI) => connectedRelayURLs.includes(relayURI))
.forEach((relayURI) => {
if (this.connectedRelays) {
const relay = this.connectedRelays.find(
(relay) => relay.url === relayURI
)
if (relay) {
// close relay connection
relay.close()
// remove relay from connectedRelays property
this.connectedRelays = this.connectedRelays.filter(
(relay) => relay.url !== relayURI
)
}
}
})
if (store.getState().relays?.connectionStatus) {
const connectionStatus = JSON.parse(
JSON.stringify(store.getState().relays?.connectionStatus)
)
relayURIs.forEach((relay) => {
delete connectionStatus[relay]
})
if (
!compareObjects(
store.getState().relays?.connectionStatus,
connectionStatus
)
) {
// Update app state
store.dispatch(setRelayConnectionStatusAction(connectionStatus))
}
}
}
} }

View File

@ -0,0 +1,309 @@
import { Event, Filter, Relay } from 'nostr-tools'
import { normalizeWebSocketURL, timeout } from '../utils'
import { SIGIT_RELAY } from '../utils/const'
/**
* Singleton class to manage relay operations.
*/
export class RelayController {
private static instance: RelayController
public connectedRelays = new Map<string, Relay>()
private constructor() {}
/**
* Provides the singleton instance of RelayController.
*
* @returns The singleton instance of RelayController.
*/
public static getInstance(): RelayController {
if (!RelayController.instance) {
RelayController.instance = new RelayController()
}
return RelayController.instance
}
/**
* Connects to a relay server if not already connected.
*
* This method checks if a relay with the given URL is already in the list of connected relays.
* If it is not connected, it attempts to establish a new connection.
* On successful connection, the relay is added to the list of connected relays and returned.
* If the connection fails, an error is logged and `null` is returned.
*
* @param relayUrl - The URL of the relay server to connect to.
* @returns A promise that resolves to the connected relay object if successful, or `null` if the connection fails.
*/
public connectRelay = async (relayUrl: string): Promise<Relay | null> => {
// Check if a relay with the same URL is already connected
const normalizedWebSocketURL = normalizeWebSocketURL(relayUrl)
const relay = this.connectedRelays.get(normalizedWebSocketURL)
if (relay) {
// If a relay is found in connectedRelay map and is connected, just return it
if (relay.connected) return relay
// If relay is found in connectedRelay map but not connected,
// remove it from map and call connectRelay method again
this.connectedRelays.delete(relayUrl)
return this.connectRelay(relayUrl)
}
// Attempt to connect to the relay using the provided URL
const newRelay = await Relay.connect(relayUrl)
.then((relay) => {
if (relay.connected) {
// Add the newly connected relay to the connected relays map
this.connectedRelays.set(relayUrl, relay)
// Return the newly connected relay
return relay
}
return null
})
.catch((err) => {
// Log an error message if the connection fails
console.error(`Relay connection failed: ${relayUrl}`, err)
// Return null to indicate connection failure
return null
})
return newRelay
}
/**
* Asynchronously retrieves multiple event from a set of relays based on a provided filter.
* If no relays are specified, it defaults to using connected relays.
*
* @param filter - The filter criteria to find the event.
* @param relays - An optional array of relay URLs to search for the event.
* @returns Returns a promise that resolves with an array of events.
*/
fetchEvents = async (
filter: Filter,
relayUrls: string[] = []
): Promise<Event[]> => {
// Add app relay to relays array and connect to all specified relays
const relayPromises = [...relayUrls, SIGIT_RELAY].map((relayUrl) =>
this.connectRelay(relayUrl)
)
// Use Promise.allSettled to wait for all promises to settle
const results = await Promise.allSettled(relayPromises)
// Extract non-null values from fulfilled promises in a single pass
const relays = results.reduce<Relay[]>((acc, result) => {
if (result.status === 'fulfilled') {
const value = result.value
if (value) {
acc.push(value)
}
}
return acc
}, [])
// Check if any relays are connected
if (relays.length === 0) {
throw new Error('No relay is connected to fetch events!')
}
const events: Event[] = []
const eventIds = new Set<string>() // To keep track of event IDs and avoid duplicates
// Create a promise for each relay subscription
const subPromises = relays.map((relay) => {
return new Promise<void>((resolve) => {
if (!relay.connected) {
console.log(`${relay.url} : Not connected!`, 'Skipping subscription')
return resolve()
}
// Subscribe to the relay with the specified filter
const sub = relay.subscribe([filter], {
// Handle incoming events
onevent: (e) => {
// Add the event to the array if it's not a duplicate
if (!eventIds.has(e.id)) {
eventIds.add(e.id) // Record the event ID
events.push(e) // Add the event to the array
}
},
// Handle the End-Of-Stream (EOSE) message
oneose: () => {
sub.close() // Close the subscription
resolve() // Resolve the promise when EOSE is received
}
})
// add a 30 sec of timeout to subscription
setTimeout(() => {
if (!sub.closed) {
sub.close()
resolve()
}
}, 30 * 1000)
})
})
// Wait for all subscriptions to complete
await Promise.allSettled(subPromises)
// It is possible that different relays will send different events and events array may contain more events then specified limit in filter
// To fix this issue we'll first sort these events and then return only limited events
if (filter.limit) {
// Sort events by creation date in descending order
events.sort((a, b) => b.created_at - a.created_at)
return events.slice(0, filter.limit)
}
return events
}
/**
* Asynchronously retrieves an event from a set of relays based on a provided filter.
* If no relays are specified, it defaults to using connected relays.
*
* @param filter - The filter criteria to find the event.
* @param relays - An optional array of relay URLs to search for the event.
* @returns Returns a promise that resolves to the found event or null if not found.
*/
fetchEvent = async (
filter: Filter,
relays: string[] = []
): Promise<Event | null> => {
const events = await this.fetchEvents(filter, relays)
// Sort events by creation date in descending order
events.sort((a, b) => b.created_at - a.created_at)
// Return the most recent event, or null if no events were received
return events[0] || null
}
/**
* Subscribes to events from multiple relays.
*
* This method connects to the specified relay URLs and subscribes to events
* using the provided filter. It handles incoming events through the given
* `eventHandler` callback and manages the subscription lifecycle.
*
* @param filter - The filter criteria to apply when subscribing to events.
* @param relayUrls - An optional array of relay URLs to connect to. The default relay URL (`SIGIT_RELAY`) is added automatically.
* @param eventHandler - A callback function to handle incoming events. It receives an `Event` object.
*
*/
subscribeForEvents = async (
filter: Filter,
relayUrls: string[] = [],
eventHandler: (event: Event) => void
) => {
// Add app relay to relays array and connect to all specified relays
const relayPromises = [...relayUrls, SIGIT_RELAY].map((relayUrl) =>
this.connectRelay(relayUrl)
)
// Use Promise.allSettled to wait for all promises to settle
const results = await Promise.allSettled(relayPromises)
// Extract non-null values from fulfilled promises in a single pass
const relays = results.reduce<Relay[]>((acc, result) => {
if (result.status === 'fulfilled') {
const value = result.value
if (value) {
acc.push(value)
}
}
return acc
}, [])
// Check if any relays are connected
if (relays.length === 0) {
throw new Error('No relay is connected to fetch events!')
}
const processedEvents: string[] = [] // To keep track of processed events
// Create a promise for each relay subscription
const subPromises = relays.map((relay) => {
return new Promise<void>((resolve) => {
// Subscribe to the relay with the specified filter
const sub = relay.subscribe([filter], {
// Handle incoming events
onevent: (e) => {
// Process event only if it hasn't been processed before
if (!processedEvents.includes(e.id)) {
processedEvents.push(e.id)
eventHandler(e) // Call the event handler with the event
}
},
// Handle the End-Of-Stream (EOSE) message
oneose: () => {
sub.close() // Close the subscription
resolve() // Resolve the promise when EOSE is received
}
})
})
})
// Wait for all subscriptions to complete
await Promise.allSettled(subPromises)
}
publish = async (
event: Event,
relayUrls: string[] = []
): Promise<string[]> => {
// Add app relay to relays array and connect to all specified relays
const relayPromises = [...relayUrls, SIGIT_RELAY].map((relayUrl) =>
this.connectRelay(relayUrl)
)
// Use Promise.allSettled to wait for all promises to settle
const results = await Promise.allSettled(relayPromises)
// Extract non-null values from fulfilled promises in a single pass
const relays = results.reduce<Relay[]>((acc, result) => {
if (result.status === 'fulfilled') {
const value = result.value
if (value) {
acc.push(value)
}
}
return acc
}, [])
// Check if any relays are connected
if (relays.length === 0) {
throw new Error('No relay is connected to publish event!')
}
const publishedOnRelays: string[] = [] // List to track which relays successfully published the event
// Create a promise for publishing the event to each connected relay
const publishPromises = relays.map(async (relay) => {
try {
await Promise.race([
relay.publish(event), // Publish the event to the relay
timeout(20 * 1000) // Set a timeout to handle cases where publishing takes too long
])
publishedOnRelays.push(relay.url) // Add the relay URL to the list of successfully published relays
} catch (err) {
console.error(`Failed to publish event on relay: ${relay.url}`, err)
}
})
// Wait for all publish operations to complete (either fulfilled or rejected)
await Promise.allSettled(publishPromises)
// Return the list of relay URLs where the event was published
return publishedOnRelays
}
}
export const relayController = RelayController.getInstance()

View File

@ -1,3 +1,4 @@
export * from './AuthController' export * from './AuthController'
export * from './MetadataController' export * from './MetadataController'
export * from './NostrController' export * from './NostrController'
export * from './RelayController'

View File

@ -1 +1,2 @@
export * from './store' export * from './store'
export * from './useDidMount'

12
src/hooks/useDidMount.ts Normal file
View File

@ -0,0 +1,12 @@
import { useRef, useEffect } from 'react'
export const useDidMount = (callback: () => void) => {
const didMount = useRef<boolean>(false)
useEffect(() => {
if (callback && !didMount.current) {
didMount.current = true
callback()
}
})
}

View File

@ -1,9 +1,15 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { CreateSignatureEventContent, Meta } from '../types' import {
CreateSignatureEventContent,
DocSignatureEvent,
Meta,
SignedEventContent
} from '../types'
import { Mark } from '../types/mark' import { Mark } from '../types/mark'
import { import {
fromUnixTimestamp, fromUnixTimestamp,
parseCreateSignatureEvent, hexToNpub,
parseNostrEvent,
parseCreateSignatureEventContent, parseCreateSignatureEventContent,
SigitMetaParseError, SigitMetaParseError,
SigitStatus, SigitStatus,
@ -12,11 +18,38 @@ import {
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { verifyEvent } from 'nostr-tools' import { verifyEvent } from 'nostr-tools'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import store from '../store/store'
import { AuthState } from '../store/auth/types'
import { NostrController } from '../controllers'
/**
* Flattened interface that combines properties `Meta`, `CreateSignatureEventContent`,
* and `Event` (event's fields are made optional and pubkey and created_at replaced with our versions)
*/
export interface FlatMeta
extends Meta,
CreateSignatureEventContent,
Partial<Omit<Event, 'pubkey' | 'created_at'>> {
// Remove pubkey and use submittedBy as `npub1${string}`
submittedBy?: `npub1${string}`
// Remove created_at and replace with createdAt
createdAt?: number
interface FlatMeta extends Meta, CreateSignatureEventContent, Partial<Event> {
// Validated create signature event // Validated create signature event
isValid: boolean isValid: boolean
// Decryption
encryptionKey: string | null
// Parsed Document Signatures
parsedSignatureEvents: {
[signer: `npub1${string}`]: DocSignatureEvent
}
// Calculated completion time
completedAt?: number
// Calculated status fields // Calculated status fields
signedStatus: SigitStatus signedStatus: SigitStatus
signersStatus: { signersStatus: {
@ -33,8 +66,8 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
const [isValid, setIsValid] = useState(false) const [isValid, setIsValid] = useState(false)
const [kind, setKind] = useState<number>() const [kind, setKind] = useState<number>()
const [tags, setTags] = useState<string[][]>() const [tags, setTags] = useState<string[][]>()
const [created_at, setCreatedAt] = useState<number>() const [createdAt, setCreatedAt] = useState<number>()
const [pubkey, setPubkey] = useState<string>() // submittedBy, pubkey from nostr event const [submittedBy, setSubmittedBy] = useState<`npub1${string}`>() // submittedBy, pubkey from nostr event
const [id, setId] = useState<string>() const [id, setId] = useState<string>()
const [sig, setSig] = useState<string>() const [sig, setSig] = useState<string>()
@ -47,6 +80,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}`]: DocSignatureEvent
}>({})
const [completedAt, setCompletedAt] = useState<number>()
const [signedStatus, setSignedStatus] = useState<SigitStatus>( const [signedStatus, setSignedStatus] = useState<SigitStatus>(
SigitStatus.Partial SigitStatus.Partial
) )
@ -54,13 +93,13 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
[signer: `npub1${string}`]: SignStatus [signer: `npub1${string}`]: SignStatus
}>({}) }>({})
const [encryptionKey, setEncryptionKey] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
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
@ -70,7 +109,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
setTags(tags) setTags(tags)
// created_at in nostr events are stored in seconds // created_at in nostr events are stored in seconds
setCreatedAt(fromUnixTimestamp(created_at)) setCreatedAt(fromUnixTimestamp(created_at))
setPubkey(pubkey) setSubmittedBy(pubkey as `npub1${string}`)
setId(id) setId(id)
setSig(sig) setSig(sig)
@ -84,30 +123,108 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
setMarkConfig(markConfig) setMarkConfig(markConfig)
setZipUrl(zipUrl) setZipUrl(zipUrl)
// Parse each signature event and set signer status if (meta.keys) {
for (const npub in meta.docSignatures) { const { sender, keys } = meta.keys
try { // Retrieve the user's public key from the state
const event = await parseCreateSignatureEvent( const usersPubkey = (store.getState().auth as AuthState).usersPubkey!
meta.docSignatures[npub as `npub1${string}`] const usersNpub = hexToNpub(usersPubkey)
)
const isValidSignature = verifyEvent(event) // Check if the user's public key is in the keys object
setSignersStatus((prev) => { if (usersNpub in keys) {
return { // Instantiate the NostrController to decrypt the encryption key
...prev, const nostrController = NostrController.getInstance()
[npub]: isValidSignature const decrypted = await nostrController
? SignStatus.Signed .nip04Decrypt(sender, keys[usersNpub])
: SignStatus.Invalid .catch((err) => {
} console.log(
}) 'An error occurred in decrypting encryption key',
} catch (error) { err
setSignersStatus((prev) => { )
return { return null
...prev, })
[npub]: SignStatus.Invalid
} setEncryptionKey(decrypted)
})
} }
} }
// Temp. map to hold events and signers
const parsedSignatureEventsMap = new Map<
`npub1${string}`,
DocSignatureEvent
>()
const signerStatusMap = new Map<`npub1${string}`, SignStatus>()
const getPrevSignerSig = (npub: `npub1${string}`) => {
if (signers[0] === npub) {
return sig
}
// find the index of signer
const currentSignerIndex = signers.findIndex(
(signer) => signer === npub
)
// return if could not found user in signer's list
if (currentSignerIndex === -1) return
// find prev signer
const prevSigner = signers[currentSignerIndex - 1]
// get the signature of prev signer
return parsedSignatureEventsMap.get(prevSigner)?.sig
}
for (const npub in meta.docSignatures) {
try {
// Parse each signature event
const event = await parseNostrEvent(
meta.docSignatures[npub as `npub1${string}`]
)
// 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)
} catch (error) {
signerStatusMap.set(npub as `npub1${string}`, SignStatus.Invalid)
}
}
parsedSignatureEventsMap.forEach((event, npub) => {
const isValidSignature = verifyEvent(event)
if (isValidSignature) {
// get the signature of prev signer from the content of current signers signedEvent
const prevSignersSig = getPrevSignerSig(npub)
try {
const obj: SignedEventContent = JSON.parse(event.content)
parsedSignatureEventsMap.set(npub, {
...event,
parsedContent: obj
})
if (
obj.prevSig &&
prevSignersSig &&
obj.prevSig === prevSignersSig
) {
signerStatusMap.set(npub as `npub1${string}`, SignStatus.Signed)
}
} catch (error) {
signerStatusMap.set(npub as `npub1${string}`, SignStatus.Invalid)
}
}
})
signers
.filter((s) => !parsedSignatureEventsMap.has(s))
.forEach((s) => signerStatusMap.set(s, SignStatus.Pending))
// Get the first signer that hasn't signed
const nextSigner = signers.find((s) => !parsedSignatureEventsMap.has(s))
if (nextSigner) {
signerStatusMap.set(nextSigner, SignStatus.Awaiting)
}
setSignersStatus(Object.fromEntries(signerStatusMap))
setParsedSignatureEvents(Object.fromEntries(parsedSignatureEventsMap))
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)
@ -115,6 +232,20 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
setSignedStatus( setSignedStatus(
isCompletelySigned ? SigitStatus.Complete : SigitStatus.Partial isCompletelySigned ? SigitStatus.Complete : SigitStatus.Partial
) )
// Check if all signers signed
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)
@ -125,15 +256,15 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
}, [meta]) }, [meta])
return { return {
modifiedAt: meta.modifiedAt, modifiedAt: meta?.modifiedAt,
createSignature: meta.createSignature, createSignature: meta?.createSignature,
docSignatures: meta.docSignatures, docSignatures: meta?.docSignatures,
keys: meta.keys, keys: meta?.keys,
isValid, isValid,
kind, kind,
tags, tags,
created_at, createdAt,
pubkey, submittedBy,
id, id,
sig, sig,
signers, signers,
@ -142,7 +273,10 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
markConfig, markConfig,
title, title,
zipUrl, zipUrl,
parsedSignatureEvents,
completedAt,
signedStatus, signedStatus,
signersStatus signersStatus,
encryptionKey
} }
} }

View File

@ -0,0 +1,70 @@
import { useEffect, useState } from 'react'
import { ProfileMetadata } from '../types'
import { MetadataController } from '../controllers'
import { npubToHex } from '../utils'
import { Event, kinds } from 'nostr-tools'
/**
* Extracts profiles from metadata events
* @param pubkeys Array of npubs to check
* @returns ProfileMetadata
*/
export const useSigitProfiles = (
pubkeys: `npub1${string}`[]
): { [key: string]: ProfileMetadata } => {
const [profileMetadata, setProfileMetadata] = useState<{
[key: string]: ProfileMetadata
}>({})
useEffect(() => {
if (pubkeys.length) {
const metadataController = new MetadataController()
// Remove duplicate keys
const users = new Set<string>([...pubkeys])
const handleMetadataEvent = (key: string) => (event: Event) => {
const metadataContent =
metadataController.extractProfileMetadataContent(event)
if (metadataContent) {
setProfileMetadata((prev) => ({
...prev,
[key]: metadataContent
}))
}
}
users.forEach((user) => {
const hexKey = npubToHex(user)
if (hexKey && !(hexKey in profileMetadata)) {
metadataController.on(hexKey, (kind: number, event: Event) => {
if (kind === kinds.Metadata) {
handleMetadataEvent(hexKey)(event)
}
})
metadataController
.findMetadata(hexKey)
.then((metadataEvent) => {
if (metadataEvent) handleMetadataEvent(hexKey)(metadataEvent)
})
.catch((err) => {
console.error(
`error occurred in finding metadata for: ${user}`,
err
)
})
}
})
return () => {
users.forEach((key) => {
metadataController.off(key, handleMetadataEvent(key))
})
}
}
}, [pubkeys, profileMetadata])
return profileMetadata
}

View File

@ -1,5 +1,5 @@
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import { AppBar } from '../components/AppBar/AppBar' import { AppBar } from '../components/AppBar/AppBar'
@ -25,7 +25,6 @@ import {
subscribeForSigits subscribeForSigits
} from '../utils' } from '../utils'
import { useAppSelector } from '../hooks' import { useAppSelector } from '../hooks'
import { SubCloser } from 'nostr-tools/abstract-pool'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Footer } from '../components/Footer/Footer' import { Footer } from '../components/Footer/Footer'
@ -36,6 +35,9 @@ export const MainLayout = () => {
const authState = useSelector((state: State) => state.auth) const authState = useSelector((state: State) => state.auth)
const usersAppData = useAppSelector((state) => state.userAppData) const usersAppData = useAppSelector((state) => state.userAppData)
// Ref to track if `subscribeForSigits` has been called
const hasSubscribed = useRef(false)
useEffect(() => { useEffect(() => {
const metadataController = new MetadataController() const metadataController = new MetadataController()
@ -103,21 +105,15 @@ export const MainLayout = () => {
}, [dispatch]) }, [dispatch])
useEffect(() => { useEffect(() => {
let subCloser: SubCloser | null = null
if (authState.loggedIn && usersAppData) { if (authState.loggedIn && usersAppData) {
const pubkey = authState.usersPubkey || authState.keyPair?.public const pubkey = authState.usersPubkey || authState.keyPair?.public
if (pubkey) { if (pubkey && !hasSubscribed.current) {
subscribeForSigits(pubkey).then((res) => { // Call `subscribeForSigits` only if it hasn't been called before
subCloser = res || null subscribeForSigits(pubkey)
})
}
}
return () => { // Mark `subscribeForSigits` as called
if (subCloser) { hasSubscribed.current = true
subCloser.close()
} }
} }
}, [authState, usersAppData]) }, [authState, usersAppData])
@ -149,10 +145,19 @@ export const MainLayout = () => {
if (isLoading) return <LoadingSpinner desc={loadingSpinnerDesc} /> if (isLoading) return <LoadingSpinner desc={loadingSpinnerDesc} />
const isDev = import.meta.env.MODE === 'development'
return ( return (
<> <>
<AppBar /> <AppBar />
<main className={styles.main}> <main
className={styles.main}
// Add dev debug flag to show special dev css
// (visible marks border on verify)
{...(isDev && {
'data-dev': true
})}
>
<Outlet /> <Outlet />
</main> </main>
<Footer /> <Footer />

View File

@ -0,0 +1,36 @@
@import '../styles/colors.scss';
@import '../styles/sizes.scss';
.container {
display: grid;
grid-template-columns: 0.75fr 1.5fr 0.75fr;
grid-gap: 30px;
flex-grow: 1;
}
.sidesWrap {
position: relative;
// HACK: Stop grid column from growing
min-width: 0;
}
.sides {
position: sticky;
top: $header-height + $body-vertical-padding;
}
.files {
display: flex;
flex-direction: column;
grid-gap: 15px;
}
.content {
padding: 10px;
border: 10px solid $overlay-background-color;
border-radius: 4px;
max-width: 590px;
width: 590px;
margin: 0 auto;
}

View File

@ -0,0 +1,26 @@
import { PropsWithChildren, ReactNode } from 'react'
import styles from './StickySideColumns.module.scss'
interface StickySideColumnsProps {
left?: ReactNode
right?: ReactNode
}
export const StickySideColumns = ({
left,
right,
children
}: PropsWithChildren<StickySideColumnsProps>) => {
return (
<div className={styles.container}>
<div className={`${styles.sidesWrap} ${styles.files}`}>
<div className={styles.sides}>{left}</div>
</div>
<div className={styles.content}>{children}</div>
<div className={styles.sidesWrap}>
<div className={styles.sides}>{right}</div>
</div>
</div>
)
}

View File

@ -1,27 +1,16 @@
import { Clear, DragHandle } from '@mui/icons-material'
import { import {
Box,
Button, Button,
FormControl, FormHelperText,
IconButton, ListItemIcon,
InputLabel, ListItemText,
MenuItem, MenuItem,
Paper,
Select, Select,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField, TextField,
Tooltip, Tooltip
Typography
} from '@mui/material' } from '@mui/material'
import type { Identifier, XYCoord } from 'dnd-core' import type { Identifier, XYCoord } from 'dnd-core'
import saveAs from 'file-saver' import saveAs from 'file-saver'
import JSZip from 'jszip' import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { DndProvider, useDrag, useDrop } from 'react-dnd' import { DndProvider, useDrag, useDrop } from 'react-dnd'
@ -61,14 +50,46 @@ import {
} from '../../utils' } from '../../utils'
import { Container } from '../../components/Container' import { Container } from '../../components/Container'
import styles from './style.module.scss' import styles from './style.module.scss'
import { PdfFile } from '../../types/drawing' import fileListStyles from '../../components/FileList/style.module.scss'
import { DrawTool, MarkType, PdfFile } from '../../types/drawing'
import { DrawPDFFields } from '../../components/DrawPDFFields' import { DrawPDFFields } from '../../components/DrawPDFFields'
import { Mark } from '../../types/mark.ts' import { Mark } from '../../types/mark.ts'
import { StickySideColumns } from '../../layouts/StickySideColumns.tsx'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
fa1,
faBriefcase,
faCalendarDays,
faCheckDouble,
faCircleDot,
faClock,
faCreditCard,
faEllipsis,
faEye,
faGripLines,
faHeading,
faIdCard,
faImage,
faPaperclip,
faPen,
faPhone,
faPlus,
faSignature,
faSquareCaretDown,
faSquareCheck,
faStamp,
faT,
faTableCellsLarge,
faTrash,
faUpload
} from '@fortawesome/free-solid-svg-icons'
export const CreatePage = () => { export const CreatePage = () => {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { uploadedFiles } = location.state || {} const { uploadedFiles } = location.state || {}
const [currentFile, setCurrentFile] = useState<File>()
const isActive = (file: File) => file.name === currentFile?.name
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
@ -76,9 +97,22 @@ export const CreatePage = () => {
const [authUrl, setAuthUrl] = useState<string>() const [authUrl, setAuthUrl] = useState<string>()
const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`) const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`)
const [selectedFiles, setSelectedFiles] = useState<File[]>([]) const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUploadButtonClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click()
}
}
const [userInput, setUserInput] = useState('') const [userInput, setUserInput] = useState('')
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.code === 'Enter' || event.code === 'NumpadEnter') {
event.preventDefault()
handleAddUser()
}
}
const [userRole, setUserRole] = useState<UserRole>(UserRole.signer) const [userRole, setUserRole] = useState<UserRole>(UserRole.signer)
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
@ -93,6 +127,132 @@ export const CreatePage = () => {
) )
const [drawnPdfs, setDrawnPdfs] = useState<PdfFile[]>([]) const [drawnPdfs, setDrawnPdfs] = useState<PdfFile[]>([])
const [selectedTool, setSelectedTool] = useState<DrawTool>()
const [toolbox] = useState<DrawTool[]>([
{
identifier: MarkType.TEXT,
icon: <FontAwesomeIcon icon={faT} />,
label: 'Text',
active: false
},
{
identifier: MarkType.SIGNATURE,
icon: <FontAwesomeIcon icon={faSignature} />,
label: 'Signature',
active: false
},
{
identifier: MarkType.JOBTITLE,
icon: <FontAwesomeIcon icon={faBriefcase} />,
label: 'Job Title',
active: false
},
{
identifier: MarkType.FULLNAME,
icon: <FontAwesomeIcon icon={faIdCard} />,
label: 'Full Name',
active: true
},
{
identifier: MarkType.INITIALS,
icon: <FontAwesomeIcon icon={faHeading} />,
label: 'Initials',
active: false
},
{
identifier: MarkType.DATETIME,
icon: <FontAwesomeIcon icon={faClock} />,
label: 'Date Time',
active: false
},
{
identifier: MarkType.DATE,
icon: <FontAwesomeIcon icon={faCalendarDays} />,
label: 'Date',
active: false
},
{
identifier: MarkType.NUMBER,
icon: <FontAwesomeIcon icon={fa1} />,
label: 'Number',
active: false
},
{
identifier: MarkType.IMAGES,
icon: <FontAwesomeIcon icon={faImage} />,
label: 'Images',
active: false
},
{
identifier: MarkType.CHECKBOX,
icon: <FontAwesomeIcon icon={faSquareCheck} />,
label: 'Checkbox',
active: false
},
{
identifier: MarkType.MULTIPLE,
icon: <FontAwesomeIcon icon={faCheckDouble} />,
label: 'Multiple',
active: false
},
{
identifier: MarkType.FILE,
icon: <FontAwesomeIcon icon={faPaperclip} />,
label: 'File',
active: false
},
{
identifier: MarkType.RADIO,
icon: <FontAwesomeIcon icon={faCircleDot} />,
label: 'Radio',
active: false
},
{
identifier: MarkType.SELECT,
icon: <FontAwesomeIcon icon={faSquareCaretDown} />,
label: 'Select',
active: false
},
{
identifier: MarkType.CELLS,
icon: <FontAwesomeIcon icon={faTableCellsLarge} />,
label: 'Cells',
active: false
},
{
identifier: MarkType.STAMP,
icon: <FontAwesomeIcon icon={faStamp} />,
label: 'Stamp',
active: false
},
{
identifier: MarkType.PAYMENT,
icon: <FontAwesomeIcon icon={faCreditCard} />,
label: 'Payment',
active: false
},
{
identifier: MarkType.PHONE,
icon: <FontAwesomeIcon icon={faPhone} />,
label: 'Phone',
active: false
}
])
/**
* Changes the drawing tool
* @param drawTool to draw with
*/
const handleToolSelect = (drawTool: DrawTool) => {
// If clicked on the same tool, unselect
if (drawTool.identifier === selectedTool?.identifier) {
setSelectedTool(undefined)
return
}
setSelectedTool(drawTool)
}
useEffect(() => { useEffect(() => {
users.forEach((user) => { users.forEach((user) => {
if (!(user.pubkey in metadata)) { if (!(user.pubkey in metadata)) {
@ -268,19 +428,22 @@ export const CreatePage = () => {
}) })
} }
const handleSelectFiles = (files: File[]) => { const handleSelectFiles = (event: React.ChangeEvent<HTMLInputElement>) => {
setSelectedFiles((prev) => { if (event.target.files) {
const prevFileNames = prev.map((file) => file.name) setSelectedFiles(Array.from(event.target.files))
}
const newFiles = files.filter(
(file) => !prevFileNames.includes(file.name)
)
return [...prev, ...newFiles]
})
} }
const handleRemoveFile = (fileToRemove: File) => { const handleFileClick = (id: string) => {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })
}
const handleRemoveFile = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
fileToRemove: File
) => {
event.stopPropagation()
setSelectedFiles((prevFiles) => setSelectedFiles((prevFiles) =>
prevFiles.filter((file) => file.name !== fileToRemove.name) prevFiles.filter((file) => file.name !== fileToRemove.name)
) )
@ -359,7 +522,8 @@ export const CreatePage = () => {
width: drawnField.width width: drawnField.width
}, },
npub: drawnField.counterpart, npub: drawnField.counterpart,
pdfFileHash: fileHash pdfFileHash: fileHash,
fileName: drawnPdf.file.name
} }
}) })
}) })
@ -435,7 +599,7 @@ export const CreatePage = () => {
if (!arraybuffer) return null if (!arraybuffer) return null
return new File([new Blob([arraybuffer])], `${unixNow}.sigit.zip`, { return new File([new Blob([arraybuffer])], `${unixNow()}.sigit.zip`, {
type: 'application/zip' type: 'application/zip'
}) })
} }
@ -672,6 +836,7 @@ export const CreatePage = () => {
return return
} }
setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptZipFile( const encryptedArrayBuffer = await encryptZipFile(
arrayBuffer, arrayBuffer,
encryptionKey encryptionKey
@ -700,94 +865,200 @@ export const CreatePage = () => {
<> <>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<Container className={styles.container}> <Container className={styles.container}>
<TextField <StickySideColumns
label="Title" left={
value={title} <div className={styles.flexWrap}>
onChange={(e) => setTitle(e.target.value)} <div className={styles.inputWrapper}>
variant="outlined" <TextField
/> placeholder="Title"
size="small"
<Box> type="text"
<MuiFileInput value={title}
fullWidth onChange={(e) => setTitle(e.target.value)}
multiple sx={{
placeholder="Choose Files" width: '100%',
value={selectedFiles} fontSize: '16px',
onChange={(value) => handleSelectFiles(value)} '& .MuiInputBase-input': {
/> padding: '7px 14px'
},
{selectedFiles.length > 0 && ( '& .MuiOutlinedInput-notchedOutline': {
<ul> display: 'none'
{selectedFiles.map((file, index) => ( }
<li key={index}> }}
<Typography component="label">{file.name}</Typography> />
<IconButton onClick={() => handleRemoveFile(file)}> </div>
<Clear style={{ color: 'red' }} />{' '} <ol className={`${styles.paperGroup} ${styles.orderedFilesList}`}>
</IconButton> {selectedFiles.length > 0 &&
</li> selectedFiles.map((file, index) => (
))} <div
</ul> key={index}
)} className={`${fileListStyles.fileItem} ${isActive(file) && fileListStyles.active}`}
</Box> onClick={() => {
handleFileClick('file-' + file.name)
<Typography component="label" variant="h6"> setCurrentFile(file)
Add Counterparts }}
</Typography> >
<Box className={styles.inputBlock}> <>
<Box className={styles.inputBlock}> <span className={styles.fileName}>{file.name}</span>
<TextField <Button
label="nip05 / npub" variant="text"
value={userInput} onClick={(event) => handleRemoveFile(event, file)}
onChange={(e) => setUserInput(e.target.value)} sx={{
helperText={error} minWidth: '44px'
error={!!error} }}
/> >
<FormControl fullWidth> <FontAwesomeIcon icon={faTrash} />
<InputLabel id="select-role-label">Role</InputLabel> </Button>
<Select </>
labelId="select-role-label" </div>
id="demo-simple-select" ))}
value={userRole} </ol>
label="Role" <Button variant="contained" onClick={handleUploadButtonClick}>
onChange={(e) => setUserRole(e.target.value as UserRole)} <FontAwesomeIcon icon={faUpload} />
> <span className={styles.uploadFileText}>Upload new files</span>
<MenuItem value={UserRole.signer}>{UserRole.signer}</MenuItem> <input
<MenuItem value={UserRole.viewer}>{UserRole.viewer}</MenuItem> ref={fileInputRef}
</Select> hidden={true}
</FormControl> multiple={true}
type="file"
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}> onChange={handleSelectFiles}
<Button />
disabled={!userInput}
onClick={handleAddUser}
variant="contained"
>
Add
</Button> </Button>
</Box> </div>
</Box> }
</Box> right={
<div className={styles.flexWrap}>
<div className={styles.inputWrapper}>
<TextField
placeholder="User (nip05 / npub)"
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={handleInputKeyDown}
error={!!error}
fullWidth
sx={{
fontSize: '16px',
'& .MuiInputBase-input': {
padding: '7px 14px'
},
'& .MuiOutlinedInput-notchedOutline': {
display: 'none'
}
}}
/>
<Select
name="add-user-role"
aria-label="role"
value={userRole}
variant="filled"
// Hide arrow for dropdown
IconComponent={() => null}
renderValue={(value) => (
<FontAwesomeIcon
color="var(--primary-main)"
icon={value === UserRole.signer ? faPen : faEye}
/>
)}
onChange={(e) => setUserRole(e.target.value as UserRole)}
sx={{
fontSize: '16px',
minWidth: '44px',
'& .MuiInputBase-input': {
padding: '7px 14px!important',
textOverflow: 'unset!important'
}
}}
>
<MenuItem value={UserRole.signer}>
<ListItemIcon>
<FontAwesomeIcon icon={faPen} />
</ListItemIcon>
<ListItemText>{UserRole.signer}</ListItemText>
</MenuItem>
<MenuItem value={UserRole.viewer} sx={{}}>
<ListItemIcon>
<FontAwesomeIcon icon={faEye} />
</ListItemIcon>
<ListItemText>{UserRole.viewer}</ListItemText>
</MenuItem>
</Select>
<Button
disabled={!userInput}
onClick={handleAddUser}
variant="contained"
aria-label="Add"
sx={{
minWidth: '44px',
padding: '11.5px 12px',
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0
}}
>
<FontAwesomeIcon icon={faPlus} />
</Button>
</div>
<DisplayUser <div className={styles.paperGroup}>
metadata={metadata} <DisplayUser
users={users} metadata={metadata}
handleUserRoleChange={handleUserRoleChange} users={users}
handleRemoveUser={handleRemoveUser} handleUserRoleChange={handleUserRoleChange}
moveSigner={moveSigner} handleRemoveUser={handleRemoveUser}
/> moveSigner={moveSigner}
/>
</div>
<DrawPDFFields <Button onClick={handleCreate} variant="contained">
metadata={metadata} Publish
users={users} </Button>
selectedFiles={selectedFiles}
onDrawFieldsChange={onDrawFieldsChange}
/>
<Box sx={{ mt: 1, mb: 5, display: 'flex', justifyContent: 'center' }}> <div className={`${styles.paperGroup} ${styles.toolbox}`}>
<Button onClick={handleCreate} variant="contained"> {toolbox.map((drawTool: DrawTool, index: number) => {
Create return (
</Button> <div
</Box> key={index}
onClick={
drawTool.active
? () => {
handleToolSelect(drawTool)
}
: () => null
}
className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''} ${!drawTool.active ? styles.comingSoon : ''}
`}
>
{drawTool.icon}
{drawTool.label}
{drawTool.active ? (
<FontAwesomeIcon icon={faEllipsis} />
) : (
<span
style={{
fontSize: '10px'
}}
>
Coming soon
</span>
)}
</div>
)
})}
</div>
{!!error && (
<FormHelperText error={!!error}>{error}</FormHelperText>
)}
</div>
}
>
<DrawPDFFields
metadata={metadata}
users={users}
selectedFiles={selectedFiles}
onDrawFieldsChange={onDrawFieldsChange}
selectedTool={selectedTool}
/>
</StickySideColumns>
</Container> </Container>
</> </>
) )
@ -809,80 +1080,93 @@ const DisplayUser = ({
moveSigner moveSigner
}: DisplayUsersProps) => { }: DisplayUsersProps) => {
return ( return (
<TableContainer component={Paper} elevation={3} sx={{ marginTop: '20px' }}> <>
<Table> <DndProvider backend={HTML5Backend}>
<TableHead> {users
<TableRow> .filter((user) => user.role === UserRole.signer)
<TableCell className={styles.tableHeaderCell}>User</TableCell> .map((user, index) => (
<TableCell className={styles.tableHeaderCell}>Role</TableCell> <SignerRow
<TableCell>Action</TableCell> key={`signer-${index}`}
</TableRow> userMeta={metadata[user.pubkey]}
</TableHead> user={user}
<TableBody> index={index}
<DndProvider backend={HTML5Backend}> moveSigner={moveSigner}
{users handleUserRoleChange={handleUserRoleChange}
.filter((user) => user.role === UserRole.signer) handleRemoveUser={handleRemoveUser}
.map((user, index) => ( />
<SignerRow ))}
key={`signer-${index}`} </DndProvider>
userMeta={metadata[user.pubkey]} {users
user={user} .filter((user) => user.role === UserRole.viewer)
index={index} .map((user, index) => {
moveSigner={moveSigner} const userMeta = metadata[user.pubkey]
handleUserRoleChange={handleUserRoleChange} return (
handleRemoveUser={handleRemoveUser} <div className={styles.user} key={index}>
<div className={styles.avatar}>
<UserAvatar
pubkey={user.pubkey}
name={
userMeta?.display_name ||
userMeta?.name ||
shorten(hexToNpub(user.pubkey))
}
image={userMeta?.picture}
/> />
))} </div>
</DndProvider> <Select
{users name={`change-user-role-${user.pubkey}`}
.filter((user) => user.role === UserRole.viewer) aria-label="role"
.map((user, index) => { value={user.role}
const userMeta = metadata[user.pubkey] variant="outlined"
return ( IconComponent={() => null}
<TableRow key={index}> renderValue={(value) => (
<TableCell className={styles.tableCell}> <FontAwesomeIcon
<UserAvatar fontSize={'14px'}
pubkey={user.pubkey} color="var(--primary-main)"
name={ icon={value === UserRole.signer ? faPen : faEye}
userMeta?.display_name || />
userMeta?.name || )}
shorten(hexToNpub(user.pubkey)) onChange={(e) =>
} handleUserRoleChange(e.target.value as UserRole, user.pubkey)
image={userMeta?.picture} }
/> sx={{
</TableCell> fontSize: '16px',
<TableCell className={styles.tableCell}> minWidth: '34px',
<Select maxWidth: '34px',
fullWidth minHeight: '34px',
value={user.role} maxHeight: '34px',
onChange={(e) => '& .MuiInputBase-input': {
handleUserRoleChange( padding: '10px !important',
e.target.value as UserRole, textOverflow: 'unset!important'
user.pubkey },
) '& .MuiOutlinedInput-notchedOutline': {
} display: 'none'
> }
<MenuItem value={UserRole.signer}> }}
{UserRole.signer} >
</MenuItem> <MenuItem value={UserRole.signer}>{UserRole.signer}</MenuItem>
<MenuItem value={UserRole.viewer}> <MenuItem value={UserRole.viewer}>{UserRole.viewer}</MenuItem>
{UserRole.viewer} </Select>
</MenuItem> <Tooltip title="Remove User" arrow>
</Select> <Button
</TableCell> onClick={() => handleRemoveUser(user.pubkey)}
<TableCell> sx={{
<Tooltip title="Remove User" arrow> minWidth: '34px',
<IconButton onClick={() => handleRemoveUser(user.pubkey)}> height: '34px',
<Clear style={{ color: 'red' }} /> padding: 0,
</IconButton> color: 'rgba(0, 0, 0, 0.35)',
</Tooltip> '&:hover': {
</TableCell> color: 'white'
</TableRow> }
) }}
})} >
</TableBody> <FontAwesomeIcon fontSize={'14px'} icon={faTrash} />
</Table> </Button>
</TableContainer> </Tooltip>
</div>
)
})}
</>
) )
} }
@ -986,16 +1270,14 @@ const SignerRow = ({
drag(drop(ref)) drag(drop(ref))
return ( return (
<TableRow <div
sx={{ cursor: 'move', opacity }} className={styles.user}
style={{ cursor: 'move', opacity }}
data-handler-id={handlerId} data-handler-id={handlerId}
ref={ref} ref={ref}
> >
<TableCell <FontAwesomeIcon width={'14px'} fontSize={'14px'} icon={faGripLines} />
className={styles.tableCell} <div className={styles.avatar}>
sx={{ display: 'flex', alignItems: 'center', gap: '10px' }}
>
<DragHandle />
<UserAvatar <UserAvatar
pubkey={user.pubkey} pubkey={user.pubkey}
name={ name={
@ -1005,26 +1287,57 @@ const SignerRow = ({
} }
image={userMeta?.picture} image={userMeta?.picture}
/> />
</TableCell> </div>
<TableCell className={styles.tableCell}> <Select
<Select name={`change-user-role-${user.pubkey}`}
fullWidth aria-label="role"
value={user.role} value={user.role}
onChange={(e) => variant="outlined"
handleUserRoleChange(e.target.value as UserRole, user.pubkey) IconComponent={() => null}
renderValue={(value) => (
<FontAwesomeIcon
fontSize={'14px'}
color="var(--primary-main)"
icon={value === UserRole.signer ? faPen : faEye}
/>
)}
onChange={(e) =>
handleUserRoleChange(e.target.value as UserRole, user.pubkey)
}
sx={{
fontSize: '16px',
minWidth: '34px',
maxWidth: '34px',
minHeight: '34px',
maxHeight: '34px',
'& .MuiInputBase-input': {
padding: '10px !important',
textOverflow: 'unset!important'
},
'& .MuiOutlinedInput-notchedOutline': {
display: 'none'
} }
}}
>
<MenuItem value={UserRole.signer}>{UserRole.signer}</MenuItem>
<MenuItem value={UserRole.viewer}>{UserRole.viewer}</MenuItem>
</Select>
<Tooltip title="Remove User" arrow>
<Button
onClick={() => handleRemoveUser(user.pubkey)}
sx={{
minWidth: '34px',
height: '34px',
padding: 0,
color: 'rgba(0, 0, 0, 0.35)',
'&:hover': {
color: 'white'
}
}}
> >
<MenuItem value={UserRole.signer}>{UserRole.signer}</MenuItem> <FontAwesomeIcon fontSize={'14px'} icon={faTrash} />
<MenuItem value={UserRole.viewer}>{UserRole.viewer}</MenuItem> </Button>
</Select> </Tooltip>
</TableCell> </div>
<TableCell>
<Tooltip title="Remove User" arrow>
<IconButton onClick={() => handleRemoveUser(user.pubkey)}>
<Clear style={{ color: 'red' }} />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
) )
} }

View File

@ -1,41 +1,176 @@
@import '../../styles/colors.scss'; @import '../../styles/colors.scss';
.container { .flexWrap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
color: $text-color; gap: 15px;
margin-top: 10px;
gap: 10px;
width: 550px;
max-width: 550px;
.inputBlock {
display: flex;
flex-direction: column;
gap: 25px;
}
} }
.subHeader { .orderedFilesList {
border-bottom: 0.5px solid; counter-reset: item;
} list-style-type: none;
margin: 0;
.tableHeaderCell { li {
border-right: 1px solid rgba(224, 224, 224, 1);
}
.tableCell {
border-right: 1px solid rgba(224, 224, 224, 1);
height: 56px;
.user {
display: flex; display: flex;
align-items: center; align-items: center;
transition: ease 0.4s;
border-radius: 4px;
background: #ffffff;
padding: 7px 10px;
color: rgba(0, 0, 0, 0.5);
min-height: 45px;
cursor: pointer;
gap: 10px; gap: 10px;
.name { &::before {
text-align: center; content: counter(item) ' ';
cursor: pointer; counter-increment: item;
font-size: 14px;
}
:nth-child(1) {
flex-grow: 1;
font-size: 16px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
button {
color: $primary-main;
}
&:hover,
&.active,
&:focus-within {
background: $primary-main;
color: white;
button {
color: white;
}
} }
} }
} }
.uploadFileText {
margin-left: 12px;
}
.paperGroup {
border-radius: 4px;
background: white;
padding: 15px;
display: flex;
flex-direction: column;
gap: 15px;
// Automatic scrolling if paper-group gets large enough
// used for files on the left and users on the right
max-height: 350px;
overflow-x: hidden;
overflow-y: auto;
}
.inputWrapper {
display: flex;
align-items: center;
height: 34px;
overflow: hidden;
border-radius: 4px;
outline: solid 1px #dddddd;
background: white;
width: 100%;
&:focus-within {
outline-color: $primary-main;
}
}
.user {
display: flex;
gap: 10px;
font-size: 14px;
text-align: start;
justify-content: center;
align-items: center;
a:hover {
text-decoration: none;
}
}
.avatar {
flex-grow: 1;
min-width: 0;
&:first-child {
margin-left: 24px;
}
img {
// Override the default avatar size
width: 30px;
height: 30px;
}
}
.fileName {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
flex-grow: 1;
}
.toolbox {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 15px;
max-height: 450px;
overflow-x: hidden;
overflow-y: auto;
}
.toolItem {
width: 90px;
height: 90px;
transition: ease 0.2s;
display: inline-flex;
flex-direction: column;
gap: 5px;
border-radius: 4px;
padding: 10px 5px 5px 5px;
background: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.5);
align-items: center;
justify-content: center;
font-size: 14px;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
&.selected {
background: $primary-main;
color: white;
}
&:not(.selected) {
&:hover {
background: $primary-light;
color: white;
}
}
&.comingSoon {
opacity: 0.5;
cursor: not-allowed;
}
}

View File

@ -225,11 +225,11 @@ export const HomePage = () => {
type="button" type="button"
aria-label="upload files" aria-label="upload files"
> >
<input {...getInputProps()} /> <input id="file-upload" {...getInputProps()} />
{isDragActive ? ( {isDragActive ? (
<p>Drop the files here ...</p> <label htmlFor="file-upload">Drop the files here ...</label>
) : ( ) : (
<p>Click or drag files to upload!</p> <label htmlFor="file-upload">Click or drag files to upload!</label>
)} )}
</button> </button>
<div className={styles.submissions}> <div className={styles.submissions}>

View File

@ -12,7 +12,12 @@ import { MetadataController } from '../../controllers'
import { getProfileSettingsRoute } from '../../routes' import { getProfileSettingsRoute } from '../../routes'
import { State } from '../../store/rootReducer' import { State } from '../../store/rootReducer'
import { NostrJoiningBlock, ProfileMetadata } from '../../types' import { NostrJoiningBlock, ProfileMetadata } from '../../types'
import { getRoboHashPicture, hexToNpub, shorten } from '../../utils' import {
getNostrJoiningBlockNumber,
getRoboHashPicture,
hexToNpub,
shorten
} from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Container } from '../../components/Container' import { Container } from '../../components/Container'
@ -51,8 +56,7 @@ export const ProfilePage = () => {
useEffect(() => { useEffect(() => {
if (pubkey) { if (pubkey) {
metadataController getNostrJoiningBlockNumber(pubkey)
.getNostrJoiningBlockNumber(pubkey)
.then((res) => { .then((res) => {
setNostrJoiningBlock(res) setNostrJoiningBlock(res)
}) })

View File

@ -26,7 +26,11 @@ import { setMetadataEvent } from '../../../store/actions'
import { LoadingSpinner } from '../../../components/LoadingSpinner' import { LoadingSpinner } from '../../../components/LoadingSpinner'
import { LoginMethods } from '../../../store/auth/types' import { LoginMethods } from '../../../store/auth/types'
import { SmartToy } from '@mui/icons-material' import { SmartToy } from '@mui/icons-material'
import { getRoboHashPicture, unixNow } from '../../../utils' import {
getNostrJoiningBlockNumber,
getRoboHashPicture,
unixNow
} from '../../../utils'
import { Container } from '../../../components/Container' import { Container } from '../../../components/Container'
export const ProfileSettingsPage = () => { export const ProfileSettingsPage = () => {
@ -71,8 +75,7 @@ export const ProfileSettingsPage = () => {
useEffect(() => { useEffect(() => {
if (pubkey) { if (pubkey) {
metadataController getNostrJoiningBlockNumber(pubkey)
.getNostrJoiningBlockNumber(pubkey)
.then((res) => { .then((res) => {
setNostrJoiningBlock(res) setNostrJoiningBlock(res)
}) })

View File

@ -12,138 +12,44 @@ import ListItemText from '@mui/material/ListItemText'
import Switch from '@mui/material/Switch' import Switch from '@mui/material/Switch'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { NostrController } from '../../../controllers' import { Container } from '../../../components/Container'
import { useAppDispatch, useAppSelector } from '../../../hooks' import { relayController } from '../../../controllers'
import { import { useAppDispatch, useAppSelector, useDidMount } from '../../../hooks'
setRelayMapAction, import { setRelayMapAction } from '../../../store/actions'
setRelayMapUpdatedAction import { RelayConnectionState, RelayFee, RelayInfo } from '../../../types'
} from '../../../store/actions'
import {
RelayConnectionState,
RelayFee,
RelayInfoObject,
RelayMap
} from '../../../types'
import { import {
capitalizeFirstLetter, capitalizeFirstLetter,
compareObjects, compareObjects,
getRelayInfo,
getRelayMap,
hexToNpub, hexToNpub,
publishRelayMap,
shorten shorten
} from '../../../utils' } from '../../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Container } from '../../../components/Container'
export const RelaysPage = () => { export const RelaysPage = () => {
const nostrController = NostrController.getInstance()
const relaysState = useAppSelector((state) => state.relays)
const usersPubkey = useAppSelector((state) => state.auth?.usersPubkey) const usersPubkey = useAppSelector((state) => state.auth?.usersPubkey)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const [newRelayURI, setNewRelayURI] = useState<string>() const [newRelayURI, setNewRelayURI] = useState<string>()
const [newRelayURIerror, setNewRelayURIerror] = useState<string>() const [newRelayURIerror, setNewRelayURIerror] = useState<string>()
const [relayMap, setRelayMap] = useState<RelayMap | undefined>(
relaysState?.map const relayMap = useAppSelector((state) => state.relays?.map)
) const relaysInfo = useAppSelector((state) => state.relays?.info)
const [relaysInfo, setRelaysInfo] = useState<RelayInfoObject | undefined>(
relaysState?.info
)
const [displayRelaysInfo, setDisplayRelaysInfo] = useState<string[]>([])
const [relaysConnectionStatus, setRelaysConnectionStatus] = useState(
relaysState?.connectionStatus
)
const webSocketPrefix = 'wss://' const webSocketPrefix = 'wss://'
// Update relay connection status useDidMount(() => {
useEffect(() => { if (usersPubkey) {
if ( getRelayMap(usersPubkey).then((newRelayMap) => {
!compareObjects(relaysConnectionStatus, relaysState?.connectionStatus) if (!compareObjects(relayMap, newRelayMap.map)) {
) { dispatch(setRelayMapAction(newRelayMap.map))
setRelaysConnectionStatus(relaysState?.connectionStatus)
}
}, [relaysConnectionStatus, relaysState?.connectionStatus])
useEffect(() => {
if (!compareObjects(relaysInfo, relaysState?.info)) {
setRelaysInfo(relaysState?.info)
}
}, [relaysInfo, relaysState?.info])
useEffect(() => {
if (!compareObjects(relayMap, relaysState?.map)) {
setRelayMap(relaysState?.map)
}
}, [relayMap, relaysState?.map])
useEffect(() => {
let isMounted = false
const fetchData = async () => {
if (usersPubkey) {
isMounted = true
// call async func to fetch relay map
const newRelayMap = await nostrController.getRelayMap(usersPubkey)
// handle fetched relay map
if (isMounted) {
if (
!relaysState?.mapUpdated ||
(newRelayMap?.mapUpdated !== undefined &&
newRelayMap?.mapUpdated > relaysState?.mapUpdated)
) {
if (
!relaysState?.map ||
!compareObjects(relaysState.map, newRelayMap)
) {
setRelayMap(newRelayMap.map)
dispatch(setRelayMapAction(newRelayMap.map))
} else {
// Update relay map updated timestamp
dispatch(setRelayMapUpdatedAction())
}
}
} }
} })
} }
})
// Publishing relay map can take some time.
// This is why data fetch should happen only if relay map was received more than 5 minutes ago.
if (
usersPubkey &&
(!relaysState?.mapUpdated ||
Date.now() - relaysState?.mapUpdated > 5 * 60 * 1000) // 5 minutes
) {
fetchData()
// Update relay connection status
if (relaysConnectionStatus) {
const notConnectedRelays = Object.keys(relaysConnectionStatus).filter(
(key) =>
relaysConnectionStatus[key] === RelayConnectionState.NotConnected
)
if (notConnectedRelays.length) {
nostrController.connectToRelays(notConnectedRelays)
}
}
}
// cleanup func
return () => {
isMounted = false
}
}, [
dispatch,
usersPubkey,
relaysState?.map,
relaysState?.mapUpdated,
nostrController,
relaysConnectionStatus
])
useEffect(() => { useEffect(() => {
// Display notification if an empty relay map has been received // Display notification if an empty relay map has been received
@ -175,24 +81,23 @@ export const RelaysPage = () => {
if (usersPubkey) { if (usersPubkey) {
// Publish updated relay map. // Publish updated relay map.
const relayMapPublishingRes = await nostrController const relayMapPublishingRes = await publishRelayMap(
.publishRelayMap(relayMapCopy, usersPubkey, [relay]) relayMapCopy,
.catch((err) => handlePublishRelayMapError(err)) usersPubkey,
[relay]
).catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) { if (relayMapPublishingRes) {
toast.success(relayMapPublishingRes) toast.success(relayMapPublishingRes)
setRelayMap(relayMapCopy)
dispatch(setRelayMapAction(relayMapCopy)) dispatch(setRelayMapAction(relayMapCopy))
} }
} }
nostrController.disconnectFromRelays([relay])
} }
} }
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlePublishRelayMapError = (err: any) => { const handlePublishRelayMapError = (err: any) => {
const errorPrefix = 'Error while publishing Relay Map' const errorPrefix = 'Error while publishing Relay Map'
@ -224,15 +129,14 @@ export const RelaysPage = () => {
if (usersPubkey) { if (usersPubkey) {
// Publish updated relay map // Publish updated relay map
const relayMapPublishingRes = await nostrController const relayMapPublishingRes = await publishRelayMap(
.publishRelayMap(relayMapCopy, usersPubkey) relayMapCopy,
.catch((err) => handlePublishRelayMapError(err)) usersPubkey
).catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) { if (relayMapPublishingRes) {
toast.success(relayMapPublishingRes) toast.success(relayMapPublishingRes)
setRelayMap(relayMapCopy)
dispatch(setRelayMapAction(relayMapCopy)) dispatch(setRelayMapAction(relayMapCopy))
} }
} }
@ -256,29 +160,25 @@ export const RelaysPage = () => {
) )
} }
} else if (relayURI && usersPubkey) { } else if (relayURI && usersPubkey) {
const connectionStatus = await nostrController.connectToRelays([relayURI]) const relay = await relayController.connectRelay(relayURI)
if ( if (relay && relay.connected) {
connectionStatus &&
connectionStatus[relayURI] &&
connectionStatus[relayURI] === RelayConnectionState.Connected
) {
const relayMapCopy = JSON.parse(JSON.stringify(relayMap)) const relayMapCopy = JSON.parse(JSON.stringify(relayMap))
relayMapCopy[relayURI] = { write: true, read: true } relayMapCopy[relayURI] = { write: true, read: true }
// Publish updated relay map // Publish updated relay map
const relayMapPublishingRes = await nostrController const relayMapPublishingRes = await publishRelayMap(
.publishRelayMap(relayMapCopy, usersPubkey) relayMapCopy,
.catch((err) => handlePublishRelayMapError(err)) usersPubkey
).catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) { if (relayMapPublishingRes) {
setRelayMap(relayMapCopy)
setNewRelayURI('') setNewRelayURI('')
dispatch(setRelayMapAction(relayMapCopy)) dispatch(setRelayMapAction(relayMapCopy))
nostrController.getRelayInfo([relayURI]) getRelayInfo([relayURI])
toast.success(relayMapPublishingRes) toast.success(relayMapPublishingRes)
} }
@ -292,29 +192,6 @@ export const RelaysPage = () => {
} }
} }
// Handle relay open and close state
const handleRelayInfo = (relay: string) => {
if (relaysInfo) {
const info = relaysInfo[relay]
if (info) {
let displayRelaysInfoCopy: string[] = JSON.parse(
JSON.stringify(displayRelaysInfo)
)
if (displayRelaysInfoCopy.includes(relay)) {
displayRelaysInfoCopy = displayRelaysInfoCopy.filter(
(rel) => rel !== relay
)
} else {
displayRelaysInfoCopy.push(relay)
}
setDisplayRelaysInfo(displayRelaysInfoCopy)
}
}
}
return ( return (
<Container className={styles.container}> <Container className={styles.container}>
<Box className={styles.relayAddContainer}> <Box className={styles.relayAddContainer}>
@ -343,177 +220,211 @@ export const RelaysPage = () => {
</Box> </Box>
{relayMap && ( {relayMap && (
<Box className={styles.relaysContainer}> <Box className={styles.relaysContainer}>
{Object.keys(relayMap).map((relay, i) => ( {Object.keys(relayMap).map((relay) => (
<Box className={styles.relay} key={`relay_${i}`}> <RelayItem
<List> key={relay}
<ListItem> relayURI={relay}
<span isWriteRelay={relayMap[relay].write}
className={[ relayInfo={relaysInfo ? relaysInfo[relay] : undefined}
styles.connectionStatus, handleLeaveRelay={handleLeaveRelay}
relaysConnectionStatus handleRelayWriteChange={handleRelayWriteChange}
? relaysConnectionStatus[relay] === />
RelayConnectionState.Connected
? styles.connectionStatusConnected
: styles.connectionStatusNotConnected
: styles.connectionStatusUnknown
].join(' ')}
/>
{relaysInfo &&
relaysInfo[relay] &&
relaysInfo[relay].limitation &&
relaysInfo[relay].limitation?.payment_required && (
<Tooltip title="Paid Relay" arrow placement="top">
<ElectricBoltIcon
className={styles.lightningIcon}
color="warning"
onClick={() => handleRelayInfo(relay)}
/>
</Tooltip>
)}
<ListItemText primary={relay} />
<Box
className={styles.leaveRelayContainer}
onClick={() => handleLeaveRelay(relay)}
>
<LogoutIcon />
<span>Leave</span>
</Box>
</ListItem>
<Divider className={styles.relayDivider} />
<ListItem>
<ListItemText
primary="Publish to this relay?"
secondary={
relaysInfo && relaysInfo[relay] ? (
<span
onClick={() => handleRelayInfo(relay)}
className={styles.showInfo}
>
Show info{' '}
{displayRelaysInfo.includes(relay) ? (
<KeyboardArrowUpIcon
className={styles.showInfoIcon}
/>
) : (
<KeyboardArrowDownIcon
className={styles.showInfoIcon}
/>
)}
</span>
) : (
''
)
}
/>
<Switch
checked={relayMap[relay].write}
onChange={(event) => handleRelayWriteChange(relay, event)}
/>
</ListItem>
{displayRelaysInfo.includes(relay) && (
<>
<Divider className={styles.relayDivider} />
<ListItem>
<Box className={styles.relayInfoContainer}>
{relaysInfo &&
relaysInfo[relay] &&
Object.keys(relaysInfo[relay]).map((key: string) => {
const infoTitle = capitalizeFirstLetter(
key.replace('_', ' ')
)
let infoValue = (relaysInfo[relay] as any)[key]
switch (key) {
case 'pubkey':
infoValue = shorten(hexToNpub(infoValue), 15)
break
case 'limitation':
infoValue = (
<ul key={`${i}_${key}`}>
{Object.keys(infoValue).map((valueKey) => (
<li key={`${i}_${key}_${valueKey}`}>
<span
className={styles.relayInfoSubTitle}
>
{capitalizeFirstLetter(
valueKey.split('_').join(' ')
)}
:
</span>{' '}
{`${infoValue[valueKey]}`}
</li>
))}
</ul>
)
break
case 'fees':
infoValue = (
<ul>
{Object.keys(infoValue).map((valueKey) => (
<li key={`${i}_${key}_${valueKey}`}>
<span
className={styles.relayInfoSubTitle}
>
{capitalizeFirstLetter(
valueKey.split('_').join(' ')
)}
:
</span>{' '}
{`${infoValue[valueKey].map((fee: RelayFee) => `${fee.amount} ${fee.unit}`)}`}
</li>
))}
</ul>
)
break
default:
break
}
if (Array.isArray(infoValue)) {
infoValue = infoValue.join(', ')
}
return (
<span key={`${i}_${key}_container`}>
<span className={styles.relayInfoTitle}>
{infoTitle}:
</span>{' '}
{infoValue}
{key === 'pubkey' ? (
<ContentCopyIcon
className={styles.copyItem}
onClick={() => {
navigator.clipboard.writeText(
hexToNpub(
(relaysInfo[relay] as any)[key]
)
)
toast.success('Copied to clipboard', {
autoClose: 1000,
hideProgressBar: true
})
}}
/>
) : null}
</span>
)
})}
</Box>
</ListItem>
</>
)}
</List>
</Box>
))} ))}
</Box> </Box>
)} )}
</Container> </Container>
) )
} }
type RelayItemProp = {
relayURI: string
isWriteRelay: boolean
relayInfo?: RelayInfo
handleLeaveRelay: (relay: string) => void
handleRelayWriteChange: (
relay: string,
event: React.ChangeEvent<HTMLInputElement>
) => Promise<void>
}
const RelayItem = ({
relayURI,
isWriteRelay,
relayInfo,
handleLeaveRelay,
handleRelayWriteChange
}: RelayItemProp) => {
const [relayConnectionStatus, setRelayConnectionStatus] =
useState<RelayConnectionState>()
const [displayRelayInfo, setDisplayRelayInfo] = useState(false)
useDidMount(() => {
relayController.connectRelay(relayURI).then((relay) => {
if (relay && relay.connected) {
setRelayConnectionStatus(RelayConnectionState.Connected)
} else {
setRelayConnectionStatus(RelayConnectionState.NotConnected)
}
})
})
return (
<Box className={styles.relay}>
<List>
<ListItem>
<span
className={[
styles.connectionStatus,
relayConnectionStatus
? relayConnectionStatus === RelayConnectionState.Connected
? styles.connectionStatusConnected
: styles.connectionStatusNotConnected
: styles.connectionStatusUnknown
].join(' ')}
/>
{relayInfo &&
relayInfo.limitation &&
relayInfo.limitation?.payment_required && (
<Tooltip title="Paid Relay" arrow placement="top">
<ElectricBoltIcon
className={styles.lightningIcon}
color="warning"
onClick={() => setDisplayRelayInfo((prev) => !prev)}
/>
</Tooltip>
)}
<ListItemText primary={relayURI} />
<Box
className={styles.leaveRelayContainer}
onClick={() => handleLeaveRelay(relayURI)}
>
<LogoutIcon />
<span>Leave</span>
</Box>
</ListItem>
<Divider className={styles.relayDivider} />
<ListItem>
<ListItemText
primary="Publish to this relay?"
secondary={
relayInfo ? (
<span
onClick={() => setDisplayRelayInfo((prev) => !prev)}
className={styles.showInfo}
>
Show info{' '}
{displayRelayInfo ? (
<KeyboardArrowUpIcon className={styles.showInfoIcon} />
) : (
<KeyboardArrowDownIcon className={styles.showInfoIcon} />
)}
</span>
) : (
''
)
}
/>
<Switch
checked={isWriteRelay}
onChange={(event) => handleRelayWriteChange(relayURI, event)}
/>
</ListItem>
{displayRelayInfo && (
<>
<Divider className={styles.relayDivider} />
<ListItem>
<Box className={styles.relayInfoContainer}>
{relayInfo &&
Object.keys(relayInfo).map((key: string) => {
const infoTitle = capitalizeFirstLetter(
key.replace('_', ' ')
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let infoValue = (relayInfo as any)[key]
switch (key) {
case 'pubkey':
infoValue = shorten(hexToNpub(infoValue), 15)
break
case 'limitation':
infoValue = (
<ul>
{Object.keys(infoValue).map((valueKey) => (
<li key={`${relayURI}_${key}_${valueKey}`}>
<span className={styles.relayInfoSubTitle}>
{capitalizeFirstLetter(
valueKey.split('_').join(' ')
)}
:
</span>{' '}
{`${infoValue[valueKey]}`}
</li>
))}
</ul>
)
break
case 'fees':
infoValue = (
<ul>
{Object.keys(infoValue).map((valueKey) => (
<li key={`${relayURI}_${key}_${valueKey}`}>
<span className={styles.relayInfoSubTitle}>
{capitalizeFirstLetter(
valueKey.split('_').join(' ')
)}
:
</span>{' '}
{`${infoValue[valueKey].map((fee: RelayFee) => `${fee.amount} ${fee.unit}`)}`}
</li>
))}
</ul>
)
break
default:
break
}
if (Array.isArray(infoValue)) {
infoValue = infoValue.join(', ')
}
return (
<span key={`${relayURI}_${key}_container`}>
<span className={styles.relayInfoTitle}>
{infoTitle}:
</span>{' '}
{infoValue}
{key === 'pubkey' ? (
<ContentCopyIcon
className={styles.copyItem}
onClick={() => {
navigator.clipboard.writeText(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hexToNpub((relayInfo as any)[key])
)
toast.success('Copied to clipboard', {
autoClose: 1000,
hideProgressBar: true
})
}}
/>
) : null}
</span>
)
})}
</Box>
</ListItem>
</>
)}
</List>
</Box>
)
}

View File

@ -1,37 +0,0 @@
import { CurrentUserMark } from '../../types/mark.ts'
import styles from './style.module.scss'
import { Box, Button, TextField } from '@mui/material'
import { MARK_TYPE_TRANSLATION } from '../../utils/const.ts'
interface MarkFormFieldProps {
handleSubmit: (event: any) => void
handleChange: (event: any) => void
selectedMark: CurrentUserMark
selectedMarkValue: string
}
/**
* Responsible for rendering a form field connected to a mark and keeping track of its value.
*/
const MarkFormField = (props: MarkFormFieldProps) => {
const { handleSubmit, handleChange, selectedMark, selectedMarkValue } = props
const getSubmitButton = () => (selectedMark.isLast ? 'Complete' : 'Next')
return (
<div className={styles.fixedBottomForm}>
<Box component="form" onSubmit={handleSubmit}>
<TextField
id="mark-value"
label={MARK_TYPE_TRANSLATION[selectedMark.mark.type.valueOf()]}
value={selectedMarkValue}
onChange={handleChange}
/>
<Button type="submit" variant="contained">
{getSubmitButton()}
</Button>
</Box>
</div>
)
}
export default MarkFormField

View File

@ -21,7 +21,7 @@ import {
extractZipUrlAndEncryptionKey, extractZipUrlAndEncryptionKey,
generateEncryptionKey, generateEncryptionKey,
generateKeysFile, generateKeysFile,
getFilesWithHashes, getCurrentUserFiles,
getHash, getHash,
hexToNpub, hexToNpub,
isOnline, isOnline,
@ -48,6 +48,8 @@ import {
updateMarks updateMarks
} from '../../utils' } from '../../utils'
import PdfMarking from '../../components/PDFView/PdfMarking.tsx' import PdfMarking from '../../components/PDFView/PdfMarking.tsx'
import { getZipWithFiles } from '../../utils/file.ts'
import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts'
enum SignedStatus { enum SignedStatus {
Fully_Signed, Fully_Signed,
User_Is_Next_Signer, User_Is_Next_Signer,
@ -219,6 +221,29 @@ export const SignPage = () => {
} }
}, [meta, usersPubkey]) }, [meta, usersPubkey])
const handleDownload = async () => {
if (Object.entries(files).length === 0 || !meta || !usersPubkey) return
setLoadingSpinnerDesc('Generating file')
try {
const zip = await getZipWithFiles(meta, files)
const arrayBuffer = await zip.generateAsync({
type: ARRAY_BUFFER,
compression: DEFLATE,
compressionOptions: {
level: 6
}
})
if (!arrayBuffer) return
const blob = new Blob([arrayBuffer])
saveAs(blob, `exported-${unixNow()}.sigit.zip`)
} catch (error) {
console.log('error in zip:>> ', error)
if (error instanceof Error) {
toast.error(error.message || 'Error occurred in generating zip file')
}
}
}
const decrypt = useCallback( const decrypt = useCallback(
async (file: File) => { async (file: File) => {
setLoadingSpinnerDesc('Decrypting file') setLoadingSpinnerDesc('Decrypting file')
@ -929,11 +954,13 @@ export const SignPage = () => {
return ( return (
<PdfMarking <PdfMarking
files={getFilesWithHashes(files, currentFileHashes)} files={getCurrentUserFiles(files, currentFileHashes, creatorFileHashes)}
currentUserMarks={currentUserMarks} currentUserMarks={currentUserMarks}
setIsReadyToSign={setIsReadyToSign} setIsReadyToSign={setIsReadyToSign}
setCurrentUserMarks={setCurrentUserMarks} setCurrentUserMarks={setCurrentUserMarks}
setUpdatedMarks={setUpdatedMarks} setUpdatedMarks={setUpdatedMarks}
handleDownload={handleDownload}
meta={meta}
/> />
) )
} }

View File

@ -141,7 +141,7 @@ export const DisplayMeta = ({
}) })
} }
}) })
}, [users, submittedBy]) }, [users, submittedBy, metadata])
const downloadFile = async (filename: string) => { const downloadFile = async (filename: string) => {
const arrayBuffer = await files[filename].file.arrayBuffer() const arrayBuffer = await files[filename].file.arrayBuffer()

View File

@ -2,8 +2,8 @@
.container { .container {
color: $text-color; color: $text-color;
width: 550px; //width: 550px;
max-width: 550px; //max-width: 550px;
.inputBlock { .inputBlock {
position: relative; position: relative;

View File

@ -1,42 +1,29 @@
import { import { Box, Button, Divider, Tooltip, Typography } 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, kinds, verifyEvent } from 'nostr-tools' import { Event, verifyEvent } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useRef, 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 { MetadataController, NostrController } from '../../controllers'
import { import {
CreateSignatureEventContent, CreateSignatureEventContent,
Meta, DocSignatureEvent,
ProfileMetadata, Meta
SignedEventContent
} from '../../types' } from '../../types'
import { import {
decryptArrayBuffer, decryptArrayBuffer,
extractMarksFromSignedMeta, extractMarksFromSignedMeta,
extractZipUrlAndEncryptionKey,
getHash, getHash,
hexToNpub, hexToNpub,
unixNow, unixNow,
npubToHex,
parseJson, parseJson,
readContentOfZipEntry, readContentOfZipEntry,
signEventForMetaFile,
shorten, shorten,
signEventForMetaFile getCurrentUserFiles
} from '../../utils' } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Cancel, CheckCircle } from '@mui/icons-material'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import axios from 'axios' import axios from 'axios'
import { PdfFile } from '../../types/drawing.ts' import { PdfFile } from '../../types/drawing.ts'
@ -44,71 +31,172 @@ import {
addMarks, addMarks,
convertToPdfBlob, convertToPdfBlob,
convertToPdfFile, convertToPdfFile,
groupMarksByPage groupMarksByFileNamePage,
inPx
} from '../../utils/pdf.ts' } from '../../utils/pdf.ts'
import { State } from '../../store/rootReducer.ts' import { State } from '../../store/rootReducer.ts'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { getLastSignersSig } from '../../utils/sign.ts' 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 { StickySideColumns } from '../../layouts/StickySideColumns.tsx'
import { UsersDetails } from '../../components/UsersDetails.tsx/index.tsx'
import { UserAvatar } from '../../components/UserAvatar/index.tsx'
import { useSigitProfiles } from '../../hooks/useSigitProfiles.tsx'
import { TooltipChild } from '../../components/TooltipChild.tsx'
import FileList from '../../components/FileList'
import { CurrentUserFile } from '../../types/file.ts'
import { Mark } from '../../types/mark.ts'
interface PdfViewProps {
files: CurrentUserFile[]
currentFile: CurrentUserFile | null
parsedSignatureEvents: {
[signer: `npub1${string}`]: DocSignatureEvent
}
}
const SlimPdfView = ({
files,
currentFile,
parsedSignatureEvents
}: PdfViewProps) => {
const pdfRefs = useRef<(HTMLDivElement | null)[]>([])
useEffect(() => {
if (currentFile !== null && !!pdfRefs.current[currentFile.id]) {
pdfRefs.current[currentFile.id]?.scrollIntoView({
behavior: 'smooth',
block: 'end'
})
}
}, [currentFile])
return (
<div className={styles.view}>
{files.map((currentUserFile, i) => {
const { hash, filename, pdfFile, id } = currentUserFile
const signatureEvents = Object.keys(parsedSignatureEvents)
if (!hash) return
return (
<>
<div
id={filename}
ref={(el) => (pdfRefs.current[id] = el)}
key={filename}
className={styles.fileWrapper}
>
{pdfFile.pages.map((page, i) => {
const marks: Mark[] = []
signatureEvents.forEach((e) => {
const m = parsedSignatureEvents[
e as `npub1${string}`
].parsedContent?.marks.filter(
(m) => m.pdfFileHash == hash && m.location.page == i
)
if (m) {
marks.push(...m)
}
})
return (
<div className={styles.imageWrapper} key={i}>
<img draggable="false" src={page.image} />
{marks.map((m) => {
return (
<div
className={styles.mark}
key={m.id}
style={{
left: inPx(m.location.left),
top: inPx(m.location.top),
width: inPx(m.location.width),
height: inPx(m.location.height)
}}
>
{m.value}
</div>
)
})}
</div>
)
})}
</div>
{i < files.length - 1 && (
<Divider
sx={{
fontSize: '12px',
color: 'rgba(0,0,0,0.15)'
}}
>
File Separator
</Divider>
)}
</>
)
})}
</div>
)
}
export const VerifyPage = () => { export const VerifyPage = () => {
const theme = useTheme()
const textColor = theme.palette.getContrastText(
theme.palette.background.paper
)
const location = useLocation() const location = useLocation()
/** /**
* uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains meta.json * uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains meta.json
* meta will be received in navigation from create & home page in online mode * meta will be received in navigation from create & home page in online mode
*/ */
const { uploadedZip, meta: metaInNavState } = location.state || {} const { uploadedZip, meta } = location.state || {}
const {
submittedBy,
zipUrl,
encryptionKey,
signers,
viewers,
fileHashes,
parsedSignatureEvents
} = 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('')
const [selectedFile, setSelectedFile] = useState<File | null>(null) const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [meta, setMeta] = useState<Meta | null>(null)
const [submittedBy, setSubmittedBy] = useState<string>()
const [signers, setSigners] = useState<`npub1${string}`[]>([])
const [viewers, setViewers] = useState<`npub1${string}`[]>([])
const [creatorFileHashes, setCreatorFileHashes] = useState<{
[key: string]: string
}>({})
const [currentFileHashes, setCurrentFileHashes] = useState<{ const [currentFileHashes, setCurrentFileHashes] = useState<{
[key: string]: string | null [key: string]: string | null
}>({}) }>({})
const [files, setFiles] = useState<{ [filename: string]: PdfFile }>({}) const [files, setFiles] = useState<{ [filename: string]: PdfFile }>({})
const [currentFile, setCurrentFile] = useState<CurrentUserFile | null>(null)
const [signatureFileHashes, setSignatureFileHashes] = useState<{
[key: string]: string
}>(fileHashes)
useEffect(() => {
setSignatureFileHashes(fileHashes)
}, [fileHashes])
useEffect(() => {
if (Object.entries(files).length > 0) {
const tmp = getCurrentUserFiles(files, fileHashes, signatureFileHashes)
setCurrentFile(tmp[0])
}
}, [signatureFileHashes, fileHashes, files])
const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>(
{}
)
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey) const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const nostrController = NostrController.getInstance() const nostrController = NostrController.getInstance()
useEffect(() => { useEffect(() => {
if (uploadedZip) { if (uploadedZip) {
setSelectedFile(uploadedZip) setSelectedFile(uploadedZip)
} else if (metaInNavState) { } else if (meta && encryptionKey) {
const processSigit = async () => { const processSigit = async () => {
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Extracting zipUrl and encryption key from meta')
const res = await extractZipUrlAndEncryptionKey(metaInNavState)
if (!res) {
setIsLoading(false)
return
}
const {
zipUrl,
encryptionKey,
createSignatureEvent,
createSignatureContent
} = res
setLoadingSpinnerDesc('Fetching file from file server') setLoadingSpinnerDesc('Fetching file from file server')
axios axios
@ -175,12 +263,6 @@ export const VerifyPage = () => {
setCurrentFileHashes(fileHashes) setCurrentFileHashes(fileHashes)
setFiles(files) setFiles(files)
setSigners(createSignatureContent.signers)
setViewers(createSignatureContent.viewers)
setCreatorFileHashes(createSignatureContent.fileHashes)
setSubmittedBy(createSignatureEvent.pubkey)
setMeta(metaInNavState)
setIsLoading(false) setIsLoading(false)
} }
}) })
@ -197,49 +279,7 @@ export const VerifyPage = () => {
processSigit() processSigit()
} }
}, [uploadedZip, metaInNavState]) }, [encryptionKey, meta, uploadedZip, zipUrl])
useEffect(() => {
if (submittedBy) {
const metadataController = new MetadataController()
const users = [submittedBy, ...signers, ...viewers]
users.forEach((user) => {
const pubkey = npubToHex(user)!
if (!(pubkey in metadata)) {
const handleMetadataEvent = (event: Event) => {
const metadataContent =
metadataController.extractProfileMetadataContent(event)
if (metadataContent)
setMetadata((prev) => ({
...prev,
[pubkey]: metadataContent
}))
}
metadataController.on(pubkey, (kind: number, event: Event) => {
if (kind === kinds.Metadata) {
handleMetadataEvent(event)
}
})
metadataController
.findMetadata(pubkey)
.then((metadataEvent) => {
if (metadataEvent) handleMetadataEvent(metadataEvent)
})
.catch((err) => {
console.error(
`error occurred in finding metadata for: ${user}`,
err
)
})
}
})
}
}, [submittedBy, signers, viewers, metadata])
const handleVerify = async () => { const handleVerify = async () => {
if (!selectedFile) return if (!selectedFile) return
@ -278,7 +318,6 @@ export const VerifyPage = () => {
} }
} }
console.log('fileHashes :>> ', fileHashes)
setCurrentFileHashes(fileHashes) setCurrentFileHashes(fileHashes)
setLoadingSpinnerDesc('Parsing meta.json') setLoadingSpinnerDesc('Parsing meta.json')
@ -345,44 +384,9 @@ export const VerifyPage = () => {
if (!createSignatureContent) return if (!createSignatureContent) return
setSigners(createSignatureContent.signers)
setViewers(createSignatureContent.viewers)
setCreatorFileHashes(createSignatureContent.fileHashes)
setSubmittedBy(createSignatureEvent.pubkey)
setMeta(parsedMetaJson)
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
@ -398,8 +402,6 @@ export const VerifyPage = () => {
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event') setLoadingSpinnerDesc('Signing nostr event')
if (!meta) return
const prevSig = getLastSignersSig(meta, signers) const prevSig = getLastSignersSig(meta, signers)
if (!prevSig) return if (!prevSig) return
@ -419,12 +421,12 @@ export const VerifyPage = () => {
zip.file('meta.json', stringifiedMeta) zip.file('meta.json', stringifiedMeta)
const marks = extractMarksFromSignedMeta(updatedMeta) const marks = extractMarksFromSignedMeta(updatedMeta)
const marksByPage = groupMarksByPage(marks) const marksByPage = groupMarksByFileNamePage(marks)
for (const [fileName, pdf] of Object.entries(files)) { for (const [fileName, pdf] of Object.entries(files)) {
const pages = await addMarks(pdf.file, marksByPage) const pages = await addMarks(pdf.file, marksByPage[fileName])
const blob = await convertToPdfBlob(pages) const blob = await convertToPdfBlob(pages)
zip.file(`/files/${fileName}`, blob) zip.file(`files/${fileName}`, blob)
} }
const arrayBuffer = await zip const arrayBuffer = await zip
@ -450,76 +452,6 @@ export const VerifyPage = () => {
setIsLoading(false) setIsLoading(false)
} }
const displayUser = (pubkey: string, verifySignature = false) => {
const profile = metadata[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
@ -529,7 +461,24 @@ 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) const exportedBy = exportSignatureEvent.pubkey
const profile = profiles[exportedBy]
return (
<Tooltip
title={
profile?.display_name ||
profile?.name ||
shorten(hexToNpub(exportedBy))
}
placement="top"
arrow
disableInteractive
>
<TooltipChild>
<UserAvatar pubkey={exportedBy} image={profile?.picture} />
</TooltipChild>
</Tooltip>
)
} else { } else {
toast.error(`Invalid export signature!`) toast.error(`Invalid export signature!`)
return ( return (
@ -576,147 +525,36 @@ export const VerifyPage = () => {
)} )}
{meta && ( {meta && (
<> <StickySideColumns
<List left={
sx={{ <>
bgcolor: 'background.paper', {currentFile !== null && (
marginTop: 2 <FileList
}} files={getCurrentUserFiles(
subheader={ files,
<ListSubheader className={styles.subHeader}> currentFileHashes,
Meta Info signatureFileHashes
</ListSubheader> )}
} currentFile={currentFile}
> setCurrentFile={setCurrentFile}
{submittedBy && ( handleDownload={handleExport}
<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()} {displayExportedBy()}
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}> </>
<Button onClick={handleExport} variant="contained"> }
Export Sigit right={<UsersDetails meta={meta} />}
</Button> >
</Box> <SlimPdfView
</ListItem> currentFile={currentFile}
files={getCurrentUserFiles(
{signers.length > 0 && ( files,
<ListItem currentFileHashes,
sx={{ signatureFileHashes
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>
)} )}
parsedSignatureEvents={parsedSignatureEvents}
{viewers.length > 0 && ( />
<ListItem </StickySideColumns>
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}>
{Object.entries(currentFileHashes).map(
([filename, hash], index) => {
const isValidHash = creatorFileHashes[filename] === hash
return (
<Box key={`file-${index}`} className={styles.file}>
<Typography
component="label"
sx={{
color: textColor,
flexGrow: 1
}}
>
{filename}
</Typography>
{isValidHash && (
<Tooltip title="File integrity check passed" arrow>
<CheckCircle
sx={{ color: theme.palette.success.light }}
/>
</Tooltip>
)}
{!isValidHash && (
<Tooltip title="File integrity check failed" arrow>
<Cancel
sx={{ color: theme.palette.error.main }}
/>
</Tooltip>
)}
</Box>
)
}
)}
</Box>
</ListItem>
</List>
</>
)} )}
</Container> </Container>
</> </>

View File

@ -50,3 +50,41 @@
} }
} }
} }
.view {
width: 550px;
max-width: 550px;
display: flex;
flex-direction: column;
gap: 25px;
}
.imageWrapper {
position: relative;
img {
width: 100%;
display: block;
}
}
.fileWrapper {
display: flex;
flex-direction: column;
gap: 15px;
}
.mark {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
}
[data-dev='true'] {
.mark {
border: 1px dotted black;
}
}

View File

@ -16,7 +16,6 @@ export const SET_RELAY_MAP = 'SET_RELAY_MAP'
export const SET_RELAY_INFO = 'SET_RELAY_INFO' export const SET_RELAY_INFO = 'SET_RELAY_INFO'
export const SET_RELAY_MAP_UPDATED = 'SET_RELAY_MAP_UPDATED' export const SET_RELAY_MAP_UPDATED = 'SET_RELAY_MAP_UPDATED'
export const SET_MOST_POPULAR_RELAYS = 'SET_MOST_POPULAR_RELAYS' export const SET_MOST_POPULAR_RELAYS = 'SET_MOST_POPULAR_RELAYS'
export const SET_RELAY_CONNECTION_STATUS = 'SET_RELAY_CONNECTION_STATUS'
export const UPDATE_USER_APP_DATA = 'UPDATE_USER_APP_DATA' export const UPDATE_USER_APP_DATA = 'UPDATE_USER_APP_DATA'
export const UPDATE_PROCESSED_GIFT_WRAPS = 'UPDATE_PROCESSED_GIFT_WRAPS' export const UPDATE_PROCESSED_GIFT_WRAPS = 'UPDATE_PROCESSED_GIFT_WRAPS'

View File

@ -3,10 +3,9 @@ import {
SetRelayMapAction, SetRelayMapAction,
SetMostPopularRelaysAction, SetMostPopularRelaysAction,
SetRelayInfoAction, SetRelayInfoAction,
SetRelayConnectionStatusAction,
SetRelayMapUpdatedAction SetRelayMapUpdatedAction
} from './types' } from './types'
import { RelayMap, RelayInfoObject, RelayConnectionStatus } from '../../types' import { RelayMap, RelayInfoObject } from '../../types'
export const setRelayMapAction = (payload: RelayMap): SetRelayMapAction => ({ export const setRelayMapAction = (payload: RelayMap): SetRelayMapAction => ({
type: ActionTypes.SET_RELAY_MAP, type: ActionTypes.SET_RELAY_MAP,
@ -27,13 +26,6 @@ export const setMostPopularRelaysAction = (
payload payload
}) })
export const setRelayConnectionStatusAction = (
payload: RelayConnectionStatus
): SetRelayConnectionStatusAction => ({
type: ActionTypes.SET_RELAY_CONNECTION_STATUS,
payload
})
export const setRelayMapUpdatedAction = (): SetRelayMapUpdatedAction => ({ export const setRelayMapUpdatedAction = (): SetRelayMapUpdatedAction => ({
type: ActionTypes.SET_RELAY_MAP_UPDATED type: ActionTypes.SET_RELAY_MAP_UPDATED
}) })

View File

@ -5,8 +5,7 @@ const initialState: RelaysState = {
map: undefined, map: undefined,
mapUpdated: undefined, mapUpdated: undefined,
mostPopular: undefined, mostPopular: undefined,
info: undefined, info: undefined
connectionStatus: undefined
} }
const reducer = ( const reducer = (
@ -26,14 +25,8 @@ const reducer = (
info: { ...state.info, ...action.payload } info: { ...state.info, ...action.payload }
} }
case ActionTypes.SET_RELAY_CONNECTION_STATUS:
return {
...state,
connectionStatus: action.payload
}
case ActionTypes.SET_MOST_POPULAR_RELAYS: case ActionTypes.SET_MOST_POPULAR_RELAYS:
return { ...state, mostPopular: action.payload } return { ...state, mostPopular: [...action.payload] }
case ActionTypes.RESTORE_STATE: case ActionTypes.RESTORE_STATE:
return action.payload.relays return action.payload.relays

View File

@ -1,13 +1,12 @@
import * as ActionTypes from '../actionTypes' import * as ActionTypes from '../actionTypes'
import { RestoreState } from '../actions' import { RestoreState } from '../actions'
import { RelayMap, RelayInfoObject, RelayConnectionStatus } from '../../types' import { RelayMap, RelayInfoObject } from '../../types'
export type RelaysState = { export type RelaysState = {
map?: RelayMap map?: RelayMap
mapUpdated?: number mapUpdated?: number
mostPopular?: string[] mostPopular?: string[]
info?: RelayInfoObject info?: RelayInfoObject
connectionStatus?: RelayConnectionStatus
} }
export interface SetRelayMapAction { export interface SetRelayMapAction {
@ -25,11 +24,6 @@ export interface SetRelayInfoAction {
payload: RelayInfoObject payload: RelayInfoObject
} }
export interface SetRelayConnectionStatusAction {
type: typeof ActionTypes.SET_RELAY_CONNECTION_STATUS
payload: RelayConnectionStatus
}
export interface SetRelayMapUpdatedAction { export interface SetRelayMapUpdatedAction {
type: typeof ActionTypes.SET_RELAY_MAP_UPDATED type: typeof ActionTypes.SET_RELAY_MAP_UPDATED
} }
@ -39,5 +33,4 @@ export type RelaysDispatchTypes =
| SetRelayInfoAction | SetRelayInfoAction
| SetRelayMapUpdatedAction | SetRelayMapUpdatedAction
| SetMostPopularRelaysAction | SetMostPopularRelaysAction
| SetRelayConnectionStatusAction
| RestoreState | RestoreState

View File

@ -1,5 +1,6 @@
import { Mark } from './mark' import { Mark } from './mark'
import { Keys } from '../store/auth/types' import { Keys } from '../store/auth/types'
import { Event } from 'nostr-tools'
export enum UserRole { export enum UserRole {
signer = 'Signer', signer = 'Signer',
@ -44,3 +45,7 @@ export interface UserAppData {
keyPair?: Keys // this key pair is used for blossom requests authentication keyPair?: Keys // this key pair is used for blossom requests authentication
blossomUrls: string[] // array for storing Urls for the files that stores all the sigits and processedGiftWraps on blossom blossomUrls: string[] // array for storing Urls for the files that stores all the sigits and processedGiftWraps on blossom
} }
export interface DocSignatureEvent extends Event {
parsedContent?: SignedEventContent
}

View File

@ -41,9 +41,22 @@ export interface DrawTool {
} }
export enum MarkType { export enum MarkType {
TEXT = 'TEXT',
SIGNATURE = 'SIGNATURE', SIGNATURE = 'SIGNATURE',
JOBTITLE = 'JOBTITLE', JOBTITLE = 'JOBTITLE',
FULLNAME = 'FULLNAME', FULLNAME = 'FULLNAME',
INITIALS = 'INITIALS',
DATETIME = 'DATETIME',
DATE = 'DATE', DATE = 'DATE',
DATETIME = 'DATETIME' NUMBER = 'NUMBER',
IMAGES = 'IMAGES',
CHECKBOX = 'CHECKBOX',
MULTIPLE = 'MULTIPLE',
FILE = 'FILE',
RADIO = 'RADIO',
SELECT = 'SELECT',
CELLS = 'CELLS',
STAMP = 'STAMP',
PAYMENT = 'PAYMENT',
PHONE = 'PHONE'
} }

View File

@ -1,18 +1,26 @@
export class DecryptionError extends Error { export class DecryptionError extends Error {
public message: string = '' public message: string = ''
constructor(public inputError: any) { constructor(public inputError: unknown) {
super() super()
if (inputError.message.toLowerCase().includes('expected')) { // Make sure inputError has access to the .message
this.message = `The decryption key length or format is invalid.` if (inputError instanceof Error) {
} else if ( if (inputError.message.toLowerCase().includes('expected')) {
inputError.message.includes('The JWK "alg" member was inconsistent') this.message = `The decryption key length or format is invalid.`
) { } else if (
this.message = `The decryption key is invalid.` inputError.message.includes('The JWK "alg" member was inconsistent')
) {
this.message = `The decryption key is invalid.`
} else {
this.message =
inputError.message || 'An error occurred while decrypting file.'
}
} else { } else {
// We don't have message on the inputError
// Stringify whole error and set that as a message
this.message = this.message =
inputError.message || 'An error occurred while decrypting file.' JSON.stringify(inputError) || 'An error occurred while decrypting file.'
} }
this.name = 'DecryptionError' this.name = 'DecryptionError'

9
src/types/file.ts Normal file
View File

@ -0,0 +1,9 @@
import { PdfFile } from './drawing.ts'
export interface CurrentUserFile {
id: number
pdfFile: PdfFile
filename: string
hash?: string
isHashValid: boolean
}

View File

@ -1,9 +1,11 @@
import { MarkType } from './drawing' import { MarkType } from './drawing'
export interface CurrentUserMark { export interface CurrentUserMark {
id: number
mark: Mark mark: Mark
isLast: boolean isLast: boolean
isCompleted: boolean isCompleted: boolean
currentValue?: string
} }
export interface Mark { export interface Mark {
@ -12,6 +14,7 @@ export interface Mark {
pdfFileHash: string pdfFileHash: string
type: MarkType type: MarkType
location: MarkLocation location: MarkLocation
fileName: string
value?: string value?: string
} }

View File

@ -4,6 +4,11 @@ export const EMPTY: string = ''
export const MARK_TYPE_TRANSLATION: { [key: string]: string } = { export const MARK_TYPE_TRANSLATION: { [key: string]: string } = {
[MarkType.FULLNAME.valueOf()]: 'Full Name' [MarkType.FULLNAME.valueOf()]: 'Full Name'
} }
export const SIGN: string = 'Sign'
export const NEXT: string = 'Next'
export const ARRAY_BUFFER = 'arraybuffer'
export const DEFLATE = 'DEFLATE'
/** /**
* Number of milliseconds in one week. * Number of milliseconds in one week.
* Calc based on: 7 * 24 * 60 * 60 * 1000 * Calc based on: 7 * 24 * 60 * 60 * 1000

228
src/utils/dvm.ts Normal file
View File

@ -0,0 +1,228 @@
import { EventTemplate, Filter, kinds, nip19 } from 'nostr-tools'
import { compareObjects, queryNip05, unixNow } from '.'
import {
MetadataController,
NostrController,
relayController
} from '../controllers'
import { NostrJoiningBlock, RelayInfoObject } from '../types'
import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'
import store from '../store/store'
import { setRelayInfoAction } from '../store/actions'
export const getNostrJoiningBlockNumber = async (
hexKey: string
): Promise<NostrJoiningBlock | null> => {
const metadataController = new MetadataController()
const relaySet = await metadataController.findRelayListMetadata(hexKey)
const userRelays: string[] = []
// find user's relays
if (relaySet.write.length > 0) {
userRelays.push(...relaySet.write)
} else {
const metadata = await metadataController.findMetadata(hexKey)
if (!metadata) return null
const metadataContent =
metadataController.extractProfileMetadataContent(metadata)
if (metadataContent?.nip05) {
const nip05Profile = await queryNip05(metadataContent.nip05)
if (nip05Profile && nip05Profile.pubkey === hexKey) {
userRelays.push(...nip05Profile.relays)
}
}
}
if (userRelays.length === 0) return null
// filter for finding user's first kind 0 event
const eventFilter: Filter = {
kinds: [kinds.Metadata],
authors: [hexKey]
}
// find user's kind 0 event published on user's relays
const event = await relayController.fetchEvent(eventFilter, userRelays)
if (event) {
const { created_at } = event
// initialize job request
const jobEventTemplate: EventTemplate = {
content: '',
created_at: unixNow(),
kind: 68001,
tags: [
['i', `${created_at * 1000}`],
['j', 'blockChain-block-number']
]
}
const nostrController = NostrController.getInstance()
// sign job request event
const jobSignedEvent = await nostrController.signEvent(jobEventTemplate)
const relays = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://relayable.org'
]
await relayController.publish(jobSignedEvent, relays).catch((err) => {
console.error(
'Error occurred in publish blockChain-block-number DVM job',
err
)
})
const subscribeWithTimeout = (
subscription: NDKSubscription,
timeoutMs: number
): Promise<string> => {
return new Promise((resolve, reject) => {
const eventHandler = (event: NDKEvent) => {
subscription.stop()
resolve(event.content)
}
subscription.on('event', eventHandler)
// Set up a timeout to stop the subscription after a specified time
const timeout = setTimeout(() => {
subscription.stop() // Stop the subscription
reject(new Error('Subscription timed out')) // Reject the promise with a timeout error
}, timeoutMs)
// Handle subscription close event
subscription.on('close', () => clearTimeout(timeout))
})
}
const dvmNDK = new NDK({
explicitRelayUrls: relays
})
await dvmNDK.connect(2000)
// filter for getting DVM job's result
const sub = dvmNDK.subscribe({
kinds: [68002 as number],
'#e': [jobSignedEvent.id],
'#p': [jobSignedEvent.pubkey]
})
// asynchronously get block number from dvm job with 20 seconds timeout
const dvmJobResult = await subscribeWithTimeout(sub, 20000)
const encodedEventPointer = nip19.neventEncode({
id: event.id,
relays: userRelays,
author: event.pubkey,
kind: event.kind
})
return {
block: parseInt(dvmJobResult),
encodedEventPointer
}
}
return null
}
/**
* Sets information about relays into relays.info app state.
* @param relayURIs - relay URIs to get information about
*/
export const getRelayInfo = async (relayURIs: string[]) => {
// initialize job request
const jobEventTemplate: EventTemplate = {
content: '',
created_at: unixNow(),
kind: 68001,
tags: [
['i', `${JSON.stringify(relayURIs)}`],
['j', 'relay-info']
]
}
const nostrController = NostrController.getInstance()
// sign job request event
const jobSignedEvent = await nostrController.signEvent(jobEventTemplate)
const relays = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://relayable.org'
]
// publish job request
await relayController.publish(jobSignedEvent, relays)
console.log('jobSignedEvent :>> ', jobSignedEvent)
const subscribeWithTimeout = (
subscription: NDKSubscription,
timeoutMs: number
): Promise<string> => {
return new Promise((resolve, reject) => {
const eventHandler = (event: NDKEvent) => {
subscription.stop()
resolve(event.content)
}
subscription.on('event', eventHandler)
// Set up a timeout to stop the subscription after a specified time
const timeout = setTimeout(() => {
subscription.stop() // Stop the subscription
reject(new Error('Subscription timed out')) // Reject the promise with a timeout error
}, timeoutMs)
// Handle subscription close event
subscription.on('close', () => clearTimeout(timeout))
})
}
const dvmNDK = new NDK({
explicitRelayUrls: relays
})
await dvmNDK.connect(2000)
// filter for getting DVM job's result
const sub = dvmNDK.subscribe({
kinds: [68002 as number],
'#e': [jobSignedEvent.id],
'#p': [jobSignedEvent.pubkey]
})
// asynchronously get block number from dvm job with 20 seconds timeout
const dvmJobResult = await subscribeWithTimeout(sub, 20000)
if (!dvmJobResult) {
return Promise.reject(`Relay(s) information wasn't received`)
}
let relaysInfo: RelayInfoObject
try {
relaysInfo = JSON.parse(dvmJobResult)
} catch (error) {
return Promise.reject(`Invalid relay(s) information.`)
}
if (
relaysInfo &&
!compareObjects(store.getState().relays?.info, relaysInfo)
) {
store.dispatch(setRelayInfoAction(relaysInfo))
}
}

24
src/utils/file.ts Normal file
View File

@ -0,0 +1,24 @@
import { Meta } from '../types'
import { extractMarksFromSignedMeta } from './mark.ts'
import { addMarks, convertToPdfBlob, groupMarksByFileNamePage } from './pdf.ts'
import JSZip from 'jszip'
import { PdfFile } from '../types/drawing.ts'
const getZipWithFiles = async (
meta: Meta,
files: { [filename: string]: PdfFile }
): Promise<JSZip> => {
const zip = new JSZip()
const marks = extractMarksFromSignedMeta(meta)
const marksByFileNamePage = groupMarksByFileNamePage(marks)
for (const [fileName, pdf] of Object.entries(files)) {
const pages = await addMarks(pdf.file, marksByFileNamePage[fileName])
const blob = await convertToPdfBlob(pages)
zip.file(`files/${fileName}`, blob)
}
return zip
}
export { getZipWithFiles }

View File

@ -1,10 +1,13 @@
export * from './crypto' export * from './crypto'
export * from './dvm'
export * from './hash' export * from './hash'
export * from './localStorage' export * from './localStorage'
export * from './misc'
export * from './nostr'
export * from './string'
export * from './zip'
export * from './utils'
export * from './mark' export * from './mark'
export * from './meta' export * from './meta'
export * from './misc'
export * from './nostr'
export * from './relays'
export * from './string'
export * from './url'
export * from './utils'
export * from './zip'

View File

@ -2,6 +2,7 @@ import { CurrentUserMark, Mark } from '../types/mark.ts'
import { hexToNpub } from './nostr.ts' import { hexToNpub } from './nostr.ts'
import { Meta, SignedEventContent } from '../types' import { Meta, SignedEventContent } from '../types'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { EMPTY } from './const.ts'
/** /**
* Takes in an array of Marks already filtered by User. * Takes in an array of Marks already filtered by User.
@ -15,13 +16,12 @@ const getCurrentUserMarks = (
): CurrentUserMark[] => { ): CurrentUserMark[] => {
return marks.map((mark, index, arr) => { return marks.map((mark, index, arr) => {
const signedMark = signedMetaMarks.find((m) => m.id === mark.id) const signedMark = signedMetaMarks.find((m) => m.id === mark.id)
if (signedMark && !!signedMark.value) {
mark.value = signedMark.value
}
return { return {
mark, mark,
currentValue: signedMark?.value ?? EMPTY,
id: index + 1,
isLast: isLast(index, arr), isLast: isLast(index, arr),
isCompleted: !!mark.value isCompleted: !!signedMark?.value
} }
}) })
} }
@ -30,7 +30,7 @@ const getCurrentUserMarks = (
* Returns next incomplete CurrentUserMark if there is one * Returns next incomplete CurrentUserMark if there is one
* @param usersMarks * @param usersMarks
*/ */
const findNextCurrentUserMark = ( const findNextIncompleteCurrentUserMark = (
usersMarks: CurrentUserMark[] usersMarks: CurrentUserMark[]
): CurrentUserMark | undefined => { ): CurrentUserMark | undefined => {
return usersMarks.find((mark) => !mark.isCompleted) return usersMarks.find((mark) => !mark.isCompleted)
@ -99,12 +99,42 @@ const updateCurrentUserMarks = (
const isLast = <T>(index: number, arr: T[]) => index === arr.length - 1 const isLast = <T>(index: number, arr: T[]) => index === arr.length - 1
const isCurrentValueLast = (
currentUserMarks: CurrentUserMark[],
selectedMark: CurrentUserMark,
selectedMarkValue: string
) => {
const filteredMarks = currentUserMarks.filter(
(mark) => mark.id !== selectedMark.id
)
return (
isCurrentUserMarksComplete(filteredMarks) && selectedMarkValue.length > 0
)
}
const getUpdatedMark = (
selectedMark: CurrentUserMark,
selectedMarkValue: string
): CurrentUserMark => {
return {
...selectedMark,
currentValue: selectedMarkValue,
isCompleted: !!selectedMarkValue,
mark: {
...selectedMark.mark,
value: selectedMarkValue
}
}
}
export { export {
getCurrentUserMarks, getCurrentUserMarks,
filterMarksByPubkey, filterMarksByPubkey,
extractMarksFromSignedMeta, extractMarksFromSignedMeta,
isCurrentUserMarksComplete, isCurrentUserMarksComplete,
findNextCurrentUserMark, findNextIncompleteCurrentUserMark,
updateMarks, updateMarks,
updateCurrentUserMarks updateCurrentUserMarks,
isCurrentValueLast,
getUpdatedMark
} }

View File

@ -1,12 +1,14 @@
import { CreateSignatureEventContent, Meta } from '../types' import { CreateSignatureEventContent, Meta } from '../types'
import { fromUnixTimestamp, parseJson } from '.' import { fromUnixTimestamp, parseJson } from '.'
import { Event } from 'nostr-tools' import { Event, verifyEvent } from 'nostr-tools'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
export enum SignStatus { export enum SignStatus {
Signed = 'Signed', Signed = 'Signed',
Awaiting = 'Awaiting',
Pending = 'Pending', Pending = 'Pending',
Invalid = 'Invalid Sign' Invalid = 'Invalid',
Viewer = 'Viewer'
} }
export enum SigitStatus { export enum SigitStatus {
@ -62,38 +64,34 @@ 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"
} }
export interface SigitCardDisplayInfo { export interface SigitCardDisplayInfo {
createdAt?: number createdAt?: number
title?: string title?: string
submittedBy?: string submittedBy?: `npub1${string}`
signers: `npub1${string}`[] signers: `npub1${string}`[]
fileExtensions: string[] fileExtensions: string[]
signedStatus: SigitStatus signedStatus: SigitStatus
isValid: boolean
} }
/** /**
* 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
}
)
} }
} }
@ -131,13 +129,14 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => {
const sigitInfo: SigitCardDisplayInfo = { const sigitInfo: SigitCardDisplayInfo = {
signers: [], signers: [],
fileExtensions: [], fileExtensions: [],
signedStatus: SigitStatus.Partial signedStatus: SigitStatus.Partial,
isValid: false
} }
try { try {
const createSignatureEvent = await parseCreateSignatureEvent( const createSignatureEvent = await parseNostrEvent(meta.createSignature)
meta.createSignature
) sigitInfo.isValid = verifyEvent(createSignatureEvent)
// 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 +146,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) =>
@ -161,7 +154,7 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => {
) )
sigitInfo.title = createSignatureContent.title sigitInfo.title = createSignatureContent.title
sigitInfo.submittedBy = createSignatureEvent.pubkey sigitInfo.submittedBy = createSignatureEvent.pubkey as `npub1${string}`
sigitInfo.signers = createSignatureContent.signers sigitInfo.signers = createSignatureContent.signers
sigitInfo.fileExtensions = extensions sigitInfo.fileExtensions = extensions
@ -179,3 +172,29 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => {
} }
} }
} }
/**
* @param fileNames - List of filenames to check
* @returns List of extensions and if all are same
*/
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
}, [])
const isSame = extensions.every((ext) => ext === extensions[0])
return { extensions, isSame }
}
/**
* @param fileName - Filename to check
* @returns Extension string
*/
export const extractFileExtension = (fileName: string) => {
return fileName.split('.').pop()
}

View File

@ -5,7 +5,6 @@ import {
Event, Event,
EventTemplate, EventTemplate,
Filter, Filter,
SimplePool,
UnsignedEvent, UnsignedEvent,
finalizeEvent, finalizeEvent,
generateSecretKey, generateSecretKey,
@ -18,7 +17,11 @@ import {
} from 'nostr-tools' } from 'nostr-tools'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { NIP05_REGEX } from '../constants' import { NIP05_REGEX } from '../constants'
import { MetadataController, NostrController } from '../controllers' import {
MetadataController,
NostrController,
relayController
} from '../controllers'
import { import {
updateProcessedGiftWraps, updateProcessedGiftWraps,
updateUserAppData as updateUserAppDataAction updateUserAppData as updateUserAppDataAction
@ -328,20 +331,27 @@ export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => {
} }
} }
/**
* Fetches user application data based on user's public key and stored metadata.
*
* @returns The user application data or null if an error occurs or no data is found.
*/
export const getUsersAppData = async (): Promise<UserAppData | null> => { export const getUsersAppData = async (): Promise<UserAppData | null> => {
// Initialize an array to hold relay URLs
const relays: string[] = [] const relays: string[] = []
// Retrieve the user's public key and relay map from the Redux store
const usersPubkey = (store.getState().auth as AuthState).usersPubkey! const usersPubkey = (store.getState().auth as AuthState).usersPubkey!
const relayMap = store.getState().relays?.map const relayMap = store.getState().relays?.map
const nostrController = NostrController.getInstance() // Check if relayMap is undefined in the Redux store
// check if relaysMap in redux store is undefined
if (!relayMap) { if (!relayMap) {
// If relayMap is not present, fetch relay list metadata
const metadataController = new MetadataController() const metadataController = new MetadataController()
const relaySet = await metadataController const relaySet = await metadataController
.findRelayListMetadata(usersPubkey) .findRelayListMetadata(usersPubkey)
.catch((err) => { .catch((err) => {
// Log error and return null if fetching metadata fails
console.log( console.log(
`An error occurred while finding relay list metadata for ${hexToNpub(usersPubkey)}`, `An error occurred while finding relay list metadata for ${hexToNpub(usersPubkey)}`,
err err
@ -349,41 +359,42 @@ export const getUsersAppData = async (): Promise<UserAppData | null> => {
return null return null
}) })
// Return if metadata retrieval failed // Return null if metadata retrieval failed
if (!relaySet) return null if (!relaySet) return null
// Ensure relay list is not empty // Ensure that the relay list is not empty
if (relaySet.write.length === 0) return null if (relaySet.write.length === 0) return null
// Add write relays to the relays array
relays.push(...relaySet.write) relays.push(...relaySet.write)
} else { } else {
// filter write relays from user's relayMap stored in redux store // If relayMap exists, filter and add write relays from the stored map
const writeRelays = Object.keys(relayMap).filter( const writeRelays = Object.keys(relayMap).filter(
(key) => relayMap[key].write (key) => relayMap[key].write
) )
relays.push(...writeRelays) relays.push(...writeRelays)
} }
// generate an identifier for user's nip78 // Generate an identifier for the user's nip78
const hash = await getHash('938' + usersPubkey) const hash = await getHash('938' + usersPubkey)
if (!hash) return null if (!hash) return null
// Define a filter for fetching events
const filter: Filter = { const filter: Filter = {
kinds: [kinds.Application], kinds: [kinds.Application],
'#d': [hash] '#d': [hash]
} }
const encryptedContent = await nostrController const encryptedContent = await relayController
.getEvent(filter, relays) .fetchEvent(filter, relays)
.then((event) => { .then((event) => {
if (event) return event.content if (event) return event.content
// if person is using sigit for first time its possible that event is null // If no event is found, return an empty stringified object
// so we'll return empty stringified object
return '{}' return '{}'
}) })
.catch((err) => { .catch((err) => {
// Log error and show a toast notification if fetching event fails
console.log(`An error occurred in finding kind 30078 event`, err) console.log(`An error occurred in finding kind 30078 event`, err)
toast.error( toast.error(
'An error occurred in finding kind 30078 event for data storage' 'An error occurred in finding kind 30078 event for data storage'
@ -391,8 +402,10 @@ export const getUsersAppData = async (): Promise<UserAppData | null> => {
return null return null
}) })
// Return null if encrypted content retrieval fails
if (!encryptedContent) return null if (!encryptedContent) return null
// Handle case where the encrypted content is an empty object
if (encryptedContent === '{}') { if (encryptedContent === '{}') {
const secret = generateSecretKey() const secret = generateSecretKey()
const pubKey = getPublicKey(secret) const pubKey = getPublicKey(secret)
@ -408,20 +421,28 @@ export const getUsersAppData = async (): Promise<UserAppData | null> => {
} }
} }
// Get an instance of the NostrController
const nostrController = NostrController.getInstance()
// Decrypt the encrypted content
const decrypted = await nostrController const decrypted = await nostrController
.nip04Decrypt(usersPubkey, encryptedContent) .nip04Decrypt(usersPubkey, encryptedContent)
.catch((err) => { .catch((err) => {
// Log error and show a toast notification if decryption fails
console.log('An error occurred while decrypting app data', err) console.log('An error occurred while decrypting app data', err)
toast.error('An error occurred while decrypting app data') toast.error('An error occurred while decrypting app data')
return null return null
}) })
// Return null if decryption fails
if (!decrypted) return null if (!decrypted) return null
// Parse the decrypted content
const parsedContent = await parseJson<{ const parsedContent = await parseJson<{
blossomUrls: string[] blossomUrls: string[]
keyPair: Keys keyPair: Keys
}>(decrypted).catch((err) => { }>(decrypted).catch((err) => {
// Log error and show a toast notification if parsing fails
console.log( console.log(
'An error occurred in parsing the content of kind 30078 event', 'An error occurred in parsing the content of kind 30078 event',
err err
@ -430,21 +451,26 @@ export const getUsersAppData = async (): Promise<UserAppData | null> => {
return null return null
}) })
// Return null if parsing fails
if (!parsedContent) return null if (!parsedContent) return null
const { blossomUrls, keyPair } = parsedContent const { blossomUrls, keyPair } = parsedContent
// Return null if no blossom URLs are found
if (blossomUrls.length === 0) return null if (blossomUrls.length === 0) return null
// Fetch additional user app data from the first blossom URL
const dataFromBlossom = await getUserAppDataFromBlossom( const dataFromBlossom = await getUserAppDataFromBlossom(
blossomUrls[0], blossomUrls[0],
keyPair.private keyPair.private
) )
// Return null if fetching data from blossom fails
if (!dataFromBlossom) return null if (!dataFromBlossom) return null
const { sigits, processedGiftWraps } = dataFromBlossom const { sigits, processedGiftWraps } = dataFromBlossom
// Return the final user application data
return { return {
blossomUrls, blossomUrls,
keyPair, keyPair,
@ -575,10 +601,9 @@ export const updateUsersAppData = async (meta: Meta) => {
const relayMap = (store.getState().relays as RelaysState).map! const relayMap = (store.getState().relays as RelaysState).map!
const writeRelays = Object.keys(relayMap).filter((key) => relayMap[key].write) const writeRelays = Object.keys(relayMap).filter((key) => relayMap[key].write)
console.log(`publishing event kind: ${kinds.Application}`)
const publishResult = await Promise.race([ const publishResult = await Promise.race([
nostrController.publishEvent(signedEvent, writeRelays), relayController.publish(signedEvent, writeRelays),
timeout(1000 * 30) timeout(40 * 1000)
]).catch((err) => { ]).catch((err) => {
console.log('err :>> ', err) console.log('err :>> ', err)
if (err.message === 'Timeout') { if (err.message === 'Timeout') {
@ -817,15 +842,8 @@ export const subscribeForSigits = async (pubkey: string) => {
'#p': [pubkey] '#p': [pubkey]
} }
// Instantiate a new SimplePool for the subscription relayController.subscribeForEvents(filter, relaySet.read, (event) => {
const pool = new SimplePool() processReceivedEvent(event) // Process the received event
// Subscribe to the specified relays with the defined filter
return pool.subscribeMany(relaySet.read, [filter], {
// Define a callback function to handle received events
onevent: (event) => {
processReceivedEvent(event) // Process the received event
}
}) })
} }
@ -915,14 +933,10 @@ export const sendNotification = async (receiver: string, meta: Meta) => {
// Ensure relay list is not empty // Ensure relay list is not empty
if (relaySet.read.length === 0) return if (relaySet.read.length === 0) return
console.log('Publishing notifications')
// Publish the notification event to the recipient's read relays // Publish the notification event to the recipient's read relays
const nostrController = NostrController.getInstance()
// Attempt to publish the event to the relays, with a timeout of 2 minutes
await Promise.race([ await Promise.race([
nostrController.publishEvent(wrappedEvent, relaySet.read), relayController.publish(wrappedEvent, relaySet.read),
timeout(1000 * 30) timeout(40 * 1000)
]).catch((err) => { ]).catch((err) => {
// Log an error if publishing the notification event fails // Log an error if publishing the notification event fails
console.log( console.log(

View File

@ -71,14 +71,19 @@ const isPdf = (file: File) => file.type.toLowerCase().includes('pdf')
/** /**
* Reads the pdf file binaries * Reads the pdf file binaries
*/ */
const readPdf = (file: File): Promise<string> => { const readPdf = (file: File): Promise<string | ArrayBuffer> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader()
reader.onload = (e: any) => { reader.onload = (e) => {
const data = e.target.result const data = e.target?.result
// Make sure we only resolve for string or ArrayBuffer type
resolve(data) // They are accepted by PDFJS.getDocument function
if (data && typeof data !== 'undefined') {
resolve(data)
} else {
reject(new Error('File is null or undefined'))
}
} }
reader.onerror = (err) => { reader.onerror = (err) => {
@ -94,7 +99,7 @@ const readPdf = (file: File): Promise<string> => {
* Converts pdf to the images * Converts pdf to the images
* @param data pdf file bytes * @param data pdf file bytes
*/ */
const pdfToImages = async (data: any): Promise<PdfPage[]> => { const pdfToImages = async (data: string | ArrayBuffer): Promise<PdfPage[]> => {
const images: string[] = [] const images: string[] = []
const pdf = await PDFJS.getDocument(data).promise const pdf = await PDFJS.getDocument(data).promise
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
@ -142,7 +147,8 @@ const addMarks = async (
canvas.width = viewport.width canvas.width = viewport.width
await page.render({ canvasContext: context!, viewport: viewport }).promise await page.render({ canvasContext: context!, viewport: viewport }).promise
marksPerPage[i].forEach((mark) => draw(mark, context!)) if (marksPerPage && Object.hasOwn(marksPerPage, i))
marksPerPage[i]?.forEach((mark) => draw(mark, context!))
images.push(canvas.toDataURL()) images.push(canvas.toDataURL())
} }
@ -230,11 +236,11 @@ const convertToPdfFile = async (
* @function scaleMark scales remaining marks in line with SCALE * @function scaleMark scales remaining marks in line with SCALE
* @function byPage groups remaining Marks by their page marks.location.page * @function byPage groups remaining Marks by their page marks.location.page
*/ */
const groupMarksByPage = (marks: Mark[]) => { const groupMarksByFileNamePage = (marks: Mark[]) => {
return marks return marks
.filter(hasValue) .filter(hasValue)
.map(scaleMark) .map(scaleMark)
.reduce<{ [key: number]: Mark[] }>(byPage, {}) .reduce<{ [filename: string]: { [page: number]: Mark[] } }>(byPage, {})
} }
/** /**
@ -245,10 +251,21 @@ const groupMarksByPage = (marks: Mark[]) => {
* @param obj - accumulator in the reducer callback * @param obj - accumulator in the reducer callback
* @param mark - current value, i.e. Mark being examined * @param mark - current value, i.e. Mark being examined
*/ */
const byPage = (obj: { [key: number]: Mark[] }, mark: Mark) => { const byPage = (
const key = mark.location.page obj: { [filename: string]: { [page: number]: Mark[] } },
const curGroup = obj[key] ?? [] mark: Mark
return { ...obj, [key]: [...curGroup, mark] } ) => {
const filename = mark.fileName
const pageNumber = mark.location.page
const pages = obj[filename] ?? {}
const marks = pages[pageNumber] ?? []
return {
...obj,
[filename]: {
...pages,
[pageNumber]: [...marks, mark]
}
}
} }
export { export {
@ -259,5 +276,5 @@ export {
convertToPdfFile, convertToPdfFile,
addMarks, addMarks,
convertToPdfBlob, convertToPdfBlob,
groupMarksByPage groupMarksByFileNamePage
} }

View File

@ -1,9 +1,13 @@
import { Filter, SimplePool } from 'nostr-tools' import axios from 'axios'
import { Event, Filter, kinds, UnsignedEvent } from 'nostr-tools'
import { RelayList } from 'nostr-tools/kinds' import { RelayList } from 'nostr-tools/kinds'
import { Event } from 'nostr-tools' import { getRelayInfo, unixNow } from '.'
import { NostrController, relayController } from '../controllers'
import { localCache } from '../services' import { localCache } from '../services'
import { ONE_WEEK_IN_MS, SIGIT_RELAY } from './const.ts' import { setMostPopularRelaysAction } from '../store/actions'
import { RelayMap, RelaySet } from '../types' import store from '../store/store'
import { RelayMap, RelayReadStats, RelaySet, RelayStats } from '../types'
import { ONE_WEEK_IN_MS, SIGIT_RELAY } from './const'
const READ_MARKER = 'read' const READ_MARKER = 'read'
const WRITE_MARKER = 'write' const WRITE_MARKER = 'write'
@ -24,8 +28,8 @@ const findRelayListAndUpdateCache = async (
kinds: [RelayList], kinds: [RelayList],
authors: [hexKey] authors: [hexKey]
} }
const pool = new SimplePool()
const event = await pool.get(lookUpRelays, eventFilter) const event = await relayController.fetchEvent(eventFilter, lookUpRelays)
if (event) { if (event) {
await localCache.addUserRelayListMetadata(event) await localCache.addUserRelayListMetadata(event)
} }
@ -106,11 +110,176 @@ const toRelaySet = (obj: RelaySet, tag: string[]): RelaySet => {
return obj return obj
} }
/**
* Provides most popular relays.
* @param numberOfTopRelays - number representing how many most popular relays to provide
* @returns - promise that resolves into an array of most popular relays
*/
const getMostPopularRelays = async (
numberOfTopRelays: number = 30
): Promise<string[]> => {
const mostPopularRelaysState = store.getState().relays?.mostPopular
// return most popular relays from app state if present
if (mostPopularRelaysState) return mostPopularRelaysState
// relays in env
const { VITE_MOST_POPULAR_RELAYS } = import.meta.env
const hardcodedPopularRelays = (VITE_MOST_POPULAR_RELAYS || '').split(' ')
const url = `https://stats.nostr.band/stats_api?method=stats`
const response = await axios.get<RelayStats>(url).catch(() => undefined)
if (!response) {
return hardcodedPopularRelays //return hardcoded relay list
}
const data = response.data
if (!data) {
return hardcodedPopularRelays //return hardcoded relay list
}
const apiTopRelays = data.relay_stats.user_picks.read_relays
.slice(0, numberOfTopRelays)
.map((relay: RelayReadStats) => relay.d)
if (!apiTopRelays.length) {
return Promise.reject(`Couldn't fetch popular relays.`)
}
if (store.getState().auth?.loggedIn) {
store.dispatch(setMostPopularRelaysAction(apiTopRelays))
}
return apiTopRelays
}
/**
* Provides relay map.
* @param npub - user's npub
* @returns - promise that resolves into relay map and a timestamp when it has been updated.
*/
const getRelayMap = async (
npub: string
): Promise<{ map: RelayMap; mapUpdated?: number }> => {
const mostPopularRelays = await getMostPopularRelays()
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
const eventFilter: Filter = {
kinds: [kinds.RelayList],
authors: [npub]
}
const event = await relayController
.fetchEvent(eventFilter, mostPopularRelays)
.catch((err) => {
return Promise.reject(err)
})
if (event) {
// Handle founded 10002 event
const relaysMap: RelayMap = {}
// 'r' stands for 'relay'
const relayTags = event.tags.filter((tag) => tag[0] === 'r')
relayTags.forEach((tag) => {
const uri = tag[1]
const relayType = tag[2]
// if 3rd element of relay tag is undefined, relay is WRITE and READ
relaysMap[uri] = {
write: relayType ? relayType === 'write' : true,
read: relayType ? relayType === 'read' : true
}
})
Object.keys(relaysMap).forEach((relayUrl) => {
relayController.connectRelay(relayUrl)
})
getRelayInfo(Object.keys(relaysMap))
return Promise.resolve({ map: relaysMap, mapUpdated: event.created_at })
} else {
return Promise.resolve({ map: getDefaultRelayMap() })
}
}
/**
* Publishes relay map.
* @param relayMap - relay map.
* @param npub - user's npub.
* @param extraRelaysToPublish - optional relays to publish relay map.
* @returns - promise that resolves into a string representing publishing result.
*/
const publishRelayMap = async (
relayMap: RelayMap,
npub: string,
extraRelaysToPublish?: string[]
): Promise<string> => {
const timestamp = unixNow()
const relayURIs = Object.keys(relayMap)
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
const tags: string[][] = relayURIs.map((relayURI) =>
[
'r',
relayURI,
relayMap[relayURI].read && relayMap[relayURI].write
? ''
: relayMap[relayURI].write
? 'write'
: 'read'
].filter((value) => value !== '')
)
const newRelayMapEvent: UnsignedEvent = {
kind: kinds.RelayList,
tags,
content: '',
pubkey: npub,
created_at: timestamp
}
const nostrController = NostrController.getInstance()
const signedEvent = await nostrController.signEvent(newRelayMapEvent)
let relaysToPublish = relayURIs
// Add extra relays if provided
if (extraRelaysToPublish) {
relaysToPublish = [...relaysToPublish, ...extraRelaysToPublish]
}
// If relay map is empty, use most popular relay URIs
if (!relaysToPublish.length) {
relaysToPublish = await getMostPopularRelays()
}
const publishResult = await relayController.publish(
signedEvent,
relaysToPublish
)
if (publishResult && publishResult.length) {
return Promise.resolve(
`Relay Map published on: ${publishResult.join('\n')}`
)
}
return Promise.reject('Publishing updated relay map was unsuccessful.')
}
export { export {
findRelayListAndUpdateCache, findRelayListAndUpdateCache,
findRelayListInCache, findRelayListInCache,
getUserRelaySet,
getDefaultRelaySet,
getDefaultRelayMap, getDefaultRelayMap,
getDefaultRelaySet,
getMostPopularRelays,
getRelayMap,
publishRelayMap,
getUserRelaySet,
isOlderThanOneWeek isOlderThanOneWeek
} }

47
src/utils/url.ts Normal file
View File

@ -0,0 +1,47 @@
/**
* Normalizes a given URL by performing the following operations:
*
* 1. Ensures that the URL has a protocol by defaulting to 'wss://' if no protocol is provided.
* 2. Creates a `URL` object to easily manipulate and normalize the URL components.
* 3. Normalizes the pathname by:
* - Replacing multiple consecutive slashes with a single slash.
* - Removing the trailing slash if it exists.
* 4. Removes the port number if it is the default port for the protocol:
* - Port `80` for 'ws:' (WebSocket) protocol.
* - Port `443` for 'wss:' (WebSocket Secure) protocol.
* 5. Sorts the query parameters alphabetically.
* 6. Clears any fragment (hash) identifier from the URL.
*
* @param urlString - The URL string to be normalized.
* @returns A normalized URL string.
*/
export function normalizeWebSocketURL(urlString: string): string {
// If the URL string does not contain a protocol (e.g., "http://", "https://"),
// prepend "wss://" (WebSocket Secure) by default.
if (urlString.indexOf('://') === -1) urlString = 'wss://' + urlString
// Create a URL object from the provided URL string.
const url = new URL(urlString)
// Normalize the pathname by replacing multiple consecutive slashes with a single slash.
url.pathname = url.pathname.replace(/\/+/g, '/')
// Remove the trailing slash from the pathname if it exists.
if (url.pathname.endsWith('/')) url.pathname = url.pathname.slice(0, -1)
// Remove the port number if it is 80 for "ws:" protocol or 443 for "wss:" protocol, as these are default ports.
if (
(url.port === '80' && url.protocol === 'ws:') ||
(url.port === '443' && url.protocol === 'wss:')
)
url.port = ''
// Sort the search parameters alphabetically.
url.searchParams.sort()
// Clear any hash fragment from the URL.
url.hash = ''
// Return the normalized URL as a string.
return url.toString()
}

View File

@ -1,4 +1,5 @@
import { PdfFile } from '../types/drawing.ts' import { PdfFile } from '../types/drawing.ts'
import { CurrentUserFile } from '../types/file.ts'
export const compareObjects = ( export const compareObjects = (
obj1: object | null | undefined, obj1: object | null | undefined,
@ -71,12 +72,20 @@ export const timeout = (ms: number = 60000) => {
* including its name, hash, and content * including its name, hash, and content
* @param files * @param files
* @param fileHashes * @param fileHashes
* @param creatorFileHashes
*/ */
export const getFilesWithHashes = ( export const getCurrentUserFiles = (
files: { [filename: string]: PdfFile }, files: { [filename: string]: PdfFile },
fileHashes: { [key: string]: string | null } fileHashes: { [key: string]: string | null },
) => { creatorFileHashes: { [key: string]: string }
return Object.entries(files).map(([filename, pdfFile]) => { ): CurrentUserFile[] => {
return { pdfFile, filename, hash: fileHashes[filename] } return Object.entries(files).map(([filename, pdfFile], index) => {
return {
pdfFile,
filename,
id: index + 1,
...(!!fileHashes[filename] && { hash: fileHashes[filename]! }),
isHashValid: creatorFileHashes[filename] === fileHashes[filename]
}
}) })
} }

View File

@ -37,9 +37,11 @@ const readContentOfZipEntry = async <T extends OutputType>(
const loadZip = async (data: InputFileFormat): Promise<JSZip | null> => { const loadZip = async (data: InputFileFormat): Promise<JSZip | null> => {
try { try {
return await JSZip.loadAsync(data) return await JSZip.loadAsync(data)
} catch (err: any) { } catch (err) {
console.log('err in loading zip file :>> ', err) console.log('err in loading zip file :>> ', err)
toast.error(err.message || 'An error occurred in loading zip file.') if (err instanceof Error) {
toast.error(err.message || 'An error occurred in loading zip file.')
}
return null return null
} }
} }