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": {
"dev": "vite",
"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: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}\"",

View File

@ -1,9 +1,6 @@
import { useEffect, useState } from 'react'
import { Meta, ProfileMetadata } from '../../types'
import { SigitCardDisplayInfo, SigitStatus } from '../../utils'
import { Event, kinds } from 'nostr-tools'
import { Meta } from '../../types'
import { SigitCardDisplayInfo, SigitStatus, SignStatus } from '../../utils'
import { Link } from 'react-router-dom'
import { MetadataController } from '../../controllers'
import { formatTimestamp, hexToNpub, npubToHex, shorten } from '../../utils'
import { appPublicRoutes, appPrivateRoutes } from '../../routes'
import { Button, Divider, Tooltip } from '@mui/material'
@ -16,12 +13,13 @@ import {
faFile
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { UserAvatar } from '../UserAvatar'
import { UserAvatarGroup } from '../UserAvatarGroup'
import styles from './style.module.scss'
import { TooltipChild } from '../TooltipChild'
import { getExtensionIconLabel } from '../getExtensionIconLabel'
import { useSigitProfiles } from '../../hooks/useSigitProfiles'
import { useSigitMeta } from '../../hooks/useSigitMeta'
type SigitProps = {
meta: Meta
@ -35,65 +33,17 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
submittedBy,
signers,
signedStatus,
fileExtensions
fileExtensions,
isValid
} = parsedMeta
const [profiles, setProfiles] = useState<{ [key: string]: ProfileMetadata }>(
{}
)
const { signersStatus } = useSigitMeta(meta)
useEffect(() => {
const hexKeys = new Set<string>([
...signers.map((signer) => npubToHex(signer)!)
const profiles = useSigitProfiles([
...(submittedBy ? [submittedBy] : []),
...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 (
<div className={styles.itemWrapper}>
<Link
@ -112,6 +62,7 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
const profile = profiles[submittedBy]
return (
<Tooltip
key={submittedBy}
title={
profile?.display_name ||
profile?.name ||
@ -122,7 +73,11 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
disableInteractive
>
<TooltipChild>
<UserAvatar pubkey={submittedBy} image={profile?.picture} />
<DisplaySigner
status={isValid ? SignStatus.Signed : SignStatus.Invalid}
profile={profile}
pubkey={submittedBy}
/>
</TooltipChild>
</Tooltip>
)
@ -130,7 +85,7 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
{submittedBy && signers.length ? (
<Divider orientation="vertical" flexItem />
) : null}
<UserAvatarGroup className={styles.signers} max={7}>
<UserAvatarGroup max={7}>
{signers.map((signer) => {
const pubkey = npubToHex(signer)!
const profile = profiles[pubkey]
@ -147,7 +102,7 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
>
<TooltipChild>
<DisplaySigner
meta={meta}
status={signersStatus[signer]}
profile={profile}
pubkey={pubkey}
/>

View File

@ -93,26 +93,6 @@
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 {
color: rgba(0, 0, 0, 0.3);
font-size: 14px;

View File

@ -1,75 +1,59 @@
import { Badge } from '@mui/material'
import { Event, verifyEvent } from 'nostr-tools'
import { useState, useEffect } from 'react'
import { Meta, ProfileMetadata } from '../../types'
import { hexToNpub, parseJson } from '../../utils'
import { ProfileMetadata } from '../../types'
import styles from './style.module.scss'
import { UserAvatar } from '../UserAvatar'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheck, faExclamation } from '@fortawesome/free-solid-svg-icons'
enum SignStatus {
Signed = 'Signed',
Pending = 'Pending',
Invalid = 'Invalid Sign'
}
import {
faCheck,
faEllipsis,
faExclamation,
faEye,
faHourglass,
faQuestion
} from '@fortawesome/free-solid-svg-icons'
import { SignStatus } from '../../utils'
import { Spinner } from '../Spinner'
type DisplaySignerProps = {
meta: Meta
profile: ProfileMetadata
pubkey: string
status: SignStatus
}
export const DisplaySigner = ({
meta,
status,
profile,
pubkey
}: 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(() => {
if (!meta) return
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)
default:
return <FontAwesomeIcon icon={faQuestion} />
}
}
updateSignStatus()
}, [meta, pubkey])
return (
<Badge
className={styles.signer}
overlap="circular"
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
badgeContent={
signStatus !== SignStatus.Pending && (
<div className={styles.statusBadge}>
{signStatus === SignStatus.Signed && (
<FontAwesomeIcon icon={faCheck} />
)}
{signStatus === SignStatus.Invalid && (
<FontAwesomeIcon icon={faExclamation} />
)}
</div>
)
<div className={styles.statusBadge}>{getStatusIcon(status)}</div>
}
>
<UserAvatar pubkey={pubkey} image={profile?.picture} />

View File

@ -1,40 +1,27 @@
import {
AccessTime,
CalendarMonth,
ExpandMore,
Gesture,
PictureAsPdf,
Badge,
Work,
Close
} from '@mui/icons-material'
import { Close } from '@mui/icons-material'
import {
Box,
Typography,
Accordion,
AccordionDetails,
AccordionSummary,
CircularProgress,
Divider,
FormControl,
InputLabel,
MenuItem,
Select
} from '@mui/material'
import styles from './style.module.scss'
import { useEffect, useState } from 'react'
import React, { useEffect, useState } from 'react'
import * as PDFJS from 'pdfjs-dist'
import { ProfileMetadata, User } from '../../types'
import { ProfileMetadata, User, UserRole } from '../../types'
import {
PdfFile,
DrawTool,
MouseState,
PdfPage,
DrawnField,
MarkType
DrawTool
} from '../../types/drawing'
import { truncate } from 'lodash'
import { hexToNpub } from '../../utils'
import { extractFileExtension, hexToNpub } from '../../utils'
import { toPdfFiles } from '../../utils/pdf.ts'
PDFJS.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
@ -46,48 +33,14 @@ interface Props {
users: User[]
metadata: { [key: string]: ProfileMetadata }
onDrawFieldsChange: (pdfFiles: PdfFile[]) => void
selectedTool?: DrawTool
}
export const DrawPDFFields = (props: Props) => {
const { selectedFiles } = props
const { selectedFiles, selectedTool, onDrawFieldsChange, users } = props
const [pdfFiles, setPdfFiles] = useState<PdfFile[]>([])
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>({
clicked: false
@ -95,6 +48,16 @@ export const DrawPDFFields = (props: Props) => {
useEffect(() => {
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)
parsePdfPages().finally(() => {
@ -104,8 +67,8 @@ export const DrawPDFFields = (props: Props) => {
}, [selectedFiles])
useEffect(() => {
if (pdfFiles) props.onDrawFieldsChange(pdfFiles)
}, [pdfFiles])
if (pdfFiles) onDrawFieldsChange(pdfFiles)
}, [onDrawFieldsChange, pdfFiles])
/**
* Drawing events
@ -132,14 +95,14 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event
* @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
if (event.button !== 0) return
// Only allow drawing if mouse is not over other drawn element
const isOverPdfImageWrapper = event.target.tagName === 'IMG'
if (!selectedTool || !isOverPdfImageWrapper) {
if (!selectedTool) {
return
}
@ -185,11 +148,20 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event
* @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) {
const lastElementIndex = page.drawnFields.length - 1
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 width = mouseX - lastDrawnField.left
@ -216,7 +188,7 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event
* @param drawnField Which we are moving
*/
const onDrawnFieldMouseDown = (event: any) => {
const onDrawnFieldMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation()
// Proceed only if left click
@ -239,11 +211,14 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event
* @param drawnField which we are moving
*/
const onDranwFieldMouseMove = (event: any, drawnField: DrawnField) => {
const onDrawnFieldMouseMove = (
event: React.MouseEvent<HTMLDivElement>,
drawnField: DrawnField
) => {
if (mouseState.dragging) {
const { mouseX, mouseY, rect } = getMouseCoordinates(
event,
event.target.parentNode
event.currentTarget.parentElement
)
const coordsOffset = mouseState.coordsInWrapper
@ -272,7 +247,9 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event
* @param drawnField which we are resizing
*/
const onResizeHandleMouseDown = (event: any) => {
const onResizeHandleMouseDown = (
event: React.MouseEvent<HTMLSpanElement>
) => {
// Proceed only if left click
if (event.button !== 0) return
@ -288,11 +265,17 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event
* @param drawnField which we are resizing
*/
const onResizeHandleMouseMove = (event: any, drawnField: DrawnField) => {
const onResizeHandleMouseMove = (
event: React.MouseEvent<HTMLSpanElement>,
drawnField: DrawnField
) => {
if (mouseState.resizing) {
const { mouseX, mouseY } = getMouseCoordinates(
event,
event.target.parentNode.parentNode
// currentTarget = span handle
// 1st parent = drawnField
// 2nd parent = img
event.currentTarget.parentElement?.parentElement
)
const width = mouseX - drawnField.left
@ -313,7 +296,7 @@ export const DrawPDFFields = (props: Props) => {
* @param drawnFileIndex drawn file index
*/
const onRemoveHandleMouseDown = (
event: any,
event: React.MouseEvent<HTMLSpanElement>,
pdfFileIndex: number,
pdfPageIndex: number,
drawnFileIndex: number
@ -331,7 +314,9 @@ export const DrawPDFFields = (props: Props) => {
* so select can work properly
* @param event Mouse event
*/
const onUserSelectHandleMouseDown = (event: any) => {
const onUserSelectHandleMouseDown = (
event: React.MouseEvent<HTMLDivElement>
) => {
event.stopPropagation()
}
@ -341,8 +326,11 @@ export const DrawPDFFields = (props: Props) => {
* @param customTarget mouse coordinates relative to this element, if not provided
* event.target will be used
*/
const getMouseCoordinates = (event: any, customTarget?: any) => {
const target = customTarget ? customTarget : event.target
const getMouseCoordinates = (
event: React.MouseEvent<HTMLElement>,
customTarget?: HTMLElement | null
) => {
const target = customTarget ? customTarget : event.currentTarget
const rect = target.getBoundingClientRect()
const mouseX = event.clientX - rect.left //x 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
*/
const getPdfPages = (pdfFile: PdfFile, pdfFileIndex: number) => {
return (
<Box
sx={{
width: '100%'
}}
>
<>
{pdfFile.pages.map((page, pdfPageIndex: number) => {
return (
<div
key={pdfPageIndex}
style={{
border: '1px solid #c4c4c4',
marginBottom: '10px'
}}
className={`${styles.pdfImageWrapper} ${selectedTool ? styles.drawing : ''}`}
>
<img
onMouseMove={(event) => {
onMouseMove(event, page)
}}
onMouseDown={(event) => {
onMouseDown(event, page)
}}
>
<img
draggable="false"
style={{ width: '100%' }}
src={page.image}
/>
@ -431,7 +371,7 @@ export const DrawPDFFields = (props: Props) => {
key={drawnFieldIndex}
onMouseDown={onDrawnFieldMouseDown}
onMouseMove={(event) => {
onDranwFieldMouseMove(event, drawnField)
onDrawnFieldMouseMove(event, drawnField)
}}
className={styles.drawingRectangle}
style={{
@ -477,7 +417,9 @@ export const DrawPDFFields = (props: Props) => {
labelId="counterparts"
label="Counterparts"
>
{props.users.map((user, index) => {
{users
.filter((u) => u.role === UserRole.signer)
.map((user, index) => {
let displayValue = truncate(
hexToNpub(user.pubkey),
{
@ -516,7 +458,7 @@ export const DrawPDFFields = (props: Props) => {
</div>
)
})}
</Box>
</>
)
}
@ -533,57 +475,38 @@ export const DrawPDFFields = (props: Props) => {
}
return (
<Box>
<Box sx={{ mt: 1 }}>
<Typography sx={{ mb: 1 }}>Draw fields on the PDFs:</Typography>
{pdfFiles.map((pdfFile, pdfFileIndex: number) => {
<div className={styles.view}>
{selectedFiles.map((file, i) => {
const name = file.name
const extension = extractFileExtension(name)
const pdfFile = pdfFiles.find((pdf) => pdf.file.name === name)
return (
<Accordion
key={pdfFileIndex}
expanded={pdfFile.expanded}
onChange={(_event, expanded) => {
handleAccordionExpandChange(expanded, pdfFile)
}}
<React.Fragment key={name}>
<div
className={`${styles.fileWrapper} ${styles.scrollTarget}`}
id={`file-${name}`}
>
<AccordionSummary
expandIcon={<ExpandMore />}
aria-controls={`panel${pdfFileIndex}-content`}
id={`panel${pdfFileIndex}header`}
>
<PictureAsPdf sx={{ mr: 1 }} />
{pdfFile.file.name}
</AccordionSummary>
<AccordionDetails>
{getPdfPages(pdfFile, pdfFileIndex)}
</AccordionDetails>
</Accordion>
)
})}
</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>
{pdfFile ? (
getPdfPages(pdfFile, i)
) : (
<div className={styles.otherFile}>
This is a {extension} file
</div>
)}
</Box>
</div>
{i < selectedFiles.length - 1 && (
<Divider
sx={{
fontSize: '12px',
color: 'rgba(0,0,0,0.15)'
}}
>
File Separator
</Divider>
)}
</React.Fragment>
)
})}
</div>
)
}

View File

@ -1,3 +1,5 @@
@import '../../styles/sizes.scss';
.pdfFieldItem {
background: white;
padding: 10px;
@ -5,53 +7,18 @@
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 {
position: relative;
-webkit-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 {
cursor: crosshair;
}
@ -72,6 +39,10 @@
visibility: hidden;
}
&.edited {
border: 1px dotted #01aaad;
}
.resizeHandle {
position: absolute;
right: -5px;
@ -81,7 +52,14 @@
background-color: #fff;
border: 1px solid rgb(160, 160, 160);
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 {
@ -112,3 +90,29 @@
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) => {
const { location } = userMark.mark
const handleClick = () => handleMarkClick(userMark.mark.id)
const isEdited = () => selectedMark?.mark.id === userMark.mark.id
const getMarkValue = () =>
selectedMark?.mark.id === userMark.mark.id
? selectedMarkValue
: userMark.mark.value
isEdited() ? selectedMarkValue : userMark.currentValue
return (
<div
onClick={handleClick}
className={styles.drawingRectangle}
className={`${styles.drawingRectangle} ${isEdited() && styles.edited}`}
style={{
left: inPx(location.left),
top: inPx(location.top),

View File

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

View File

@ -2,6 +2,7 @@ import styles from '../DrawPDFFields/style.module.scss'
import { PdfPage } from '../../types/drawing.ts'
import { CurrentUserMark } from '../../types/mark.ts'
import PdfMarkItem from './PdfMarkItem.tsx'
import { useEffect, useRef } from 'react'
interface PdfPageProps {
page: PdfPage
currentUserMarks: CurrentUserMark[]
@ -20,17 +21,25 @@ const PdfPageItem = ({
selectedMarkValue,
selectedMark
}: 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 (
<div
className={styles.pdfImageWrapper}
style={{
border: '1px solid #c4c4c4',
marginBottom: '10px',
marginTop: '10px'
border: '1px solid #c4c4c4'
}}
>
<img draggable="false" src={page.image} style={{ width: '100%' }} />
{currentUserMarks.map((m, i) => (
<div key={i} ref={(el) => (markRefs.current[m.id] = el)}>
<PdfMarkItem
key={i}
handleMarkClick={handleMarkClick}
@ -38,6 +47,7 @@ const PdfPageItem = ({
userMark={m}
selectedMark={selectedMark}
/>
</div>
))}
</div>
)

View File

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

View File

@ -10,4 +10,8 @@
font-weight: 500;
font-size: 14px;
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)
return (
<div {...rest}>
<div className={styles.container} {...rest}>
{surplus > 1
? childrenArray.slice(0, surplus * -1).map((c) => c)
: children}

View File

@ -1,5 +1,25 @@
@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 {
width: 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 { MetadataController, NostrController } from '.'
import { appPrivateRoutes } from '../routes'
import {
setAuthState,
setMetadataEvent,
setRelayMapAction
} from '../store/actions'
import store from '../store/store'
import { SignedEvent } from '../types'
import {
base64DecodeAuthToken,
base64EncodeSignedEvent,
compareObjects,
getAuthToken,
getRelayMap,
getVisitedLink,
saveAuthToken,
compareObjects,
unixNow
} from '../utils'
import { appPrivateRoutes } from '../routes'
import { SignedEvent } from '../types'
export class AuthController {
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) {
// Navigate user to relays page if relay map is empty

View File

@ -1,28 +1,25 @@
import {
Event,
Filter,
SimplePool,
VerifiedEvent,
kinds,
validateEvent,
verifyEvent,
Event,
EventTemplate,
nip19
verifyEvent
} from 'nostr-tools'
import { NostrJoiningBlock, ProfileMetadata, RelaySet } from '../types'
import { NostrController } from '.'
import { toast } from 'react-toastify'
import { queryNip05, unixNow } from '../utils'
import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'
import { EventEmitter } from 'tseep'
import { NostrController, relayController } from '.'
import { localCache } from '../services'
import { ProfileMetadata, RelaySet } from '../types'
import {
findRelayListAndUpdateCache,
findRelayListInCache,
getDefaultRelaySet,
getMostPopularRelays,
getUserRelaySet,
isOlderThanOneWeek
} from '../utils/relays.ts'
isOlderThanOneWeek,
unixNow
} from '../utils'
export class MetadataController extends EventEmitter {
private nostrController: NostrController
@ -51,11 +48,9 @@ export class MetadataController extends EventEmitter {
authors: [hexKey] // Authored by the specified key
}
const pool = new SimplePool()
// Try to get the metadata event from a special relay (wss://purplepag.es)
const metadataEvent = await pool
.get([this.specialMetadataRelay], eventFilter)
const metadataEvent = await relayController
.fetchEvent(eventFilter, [this.specialMetadataRelay])
.catch((err) => {
console.error(err) // Log any errors
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
const mostPopularRelays = await this.nostrController.getMostPopularRelays()
const mostPopularRelays = await getMostPopularRelays()
// Query the most popular relays for metadata events
const events = await pool
.querySync(mostPopularRelays, eventFilter)
const events = await relayController
.fetchEvents(eventFilter, mostPopularRelays)
.catch((err) => {
console.error(err) // Log any errors
return null // Return null if an error occurs
@ -169,10 +165,7 @@ export class MetadataController extends EventEmitter {
[this.specialMetadataRelay],
hexKey
)) ||
(await findRelayListAndUpdateCache(
await this.nostrController.getMostPopularRelays(),
hexKey
))
(await findRelayListAndUpdateCache(await getMostPopularRelays(), hexKey))
return relayEvent ? getUserRelaySet(relayEvent.tags) : getDefaultRelaySet()
}
@ -206,143 +199,21 @@ export class MetadataController extends EventEmitter {
await this.nostrController.signEvent(newMetadataEvent)
}
await this.nostrController
.publishEvent(signedMetadataEvent, [this.specialMetadataRelay])
await relayController
.publish(signedMetadataEvent, [this.specialMetadataRelay])
.then((relays) => {
if (relays.length) {
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) => {
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 getEmptyMetadataEvent = (): Event => {

View File

@ -2,50 +2,24 @@ import NDK, {
NDKEvent,
NDKNip46Signer,
NDKPrivateKeySigner,
NDKSubscription,
NDKUser,
NostrEvent
} from '@nostr-dev-kit/ndk'
import axios from 'axios'
import {
Event,
EventTemplate,
Filter,
Relay,
SimplePool,
UnsignedEvent,
finalizeEvent,
kinds,
nip04,
nip19,
nip44
} from 'nostr-tools'
import { toast } from 'react-toastify'
import { EventEmitter } from 'tseep'
import {
setMostPopularRelaysAction,
setRelayConnectionStatusAction,
setRelayInfoAction,
updateNsecbunkerPubkey
} from '../store/actions'
import { updateNsecbunkerPubkey } from '../store/actions'
import { AuthState, LoginMethods } from '../store/auth/types'
import store from '../store/store'
import {
RelayConnectionState,
RelayConnectionStatus,
RelayInfoObject,
RelayMap,
RelayReadStats,
RelayStats,
SignedEvent
} from '../types'
import {
compareObjects,
getNsecBunkerDelegatedKey,
unixNow,
verifySignedEvent
} from '../utils'
import { getDefaultRelayMap } from '../utils/relays.ts'
import { SignedEvent } from '../types'
import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils'
export class NostrController extends EventEmitter {
private static instance: NostrController
@ -53,14 +27,13 @@ export class NostrController extends EventEmitter {
private bunkerNDK: NDK | undefined
private remoteSigner: NDKNip46Signer | undefined
private connectedRelays: Relay[] | undefined
private constructor() {
super()
}
private getNostrObject = () => {
// 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
throw new Error(
@ -223,98 +196,6 @@ export class NostrController extends EventEmitter {
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.
*
@ -650,359 +531,4 @@ export class NostrController extends EventEmitter {
generateDelegatedKey = (): string => {
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 './MetadataController'
export * from './NostrController'
export * from './RelayController'

View File

@ -1 +1,2 @@
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 { CreateSignatureEventContent, Meta } from '../types'
import {
CreateSignatureEventContent,
DocSignatureEvent,
Meta,
SignedEventContent
} from '../types'
import { Mark } from '../types/mark'
import {
fromUnixTimestamp,
parseCreateSignatureEvent,
hexToNpub,
parseNostrEvent,
parseCreateSignatureEventContent,
SigitMetaParseError,
SigitStatus,
@ -12,11 +18,38 @@ import {
import { toast } from 'react-toastify'
import { verifyEvent } 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
isValid: boolean
// Decryption
encryptionKey: string | null
// Parsed Document Signatures
parsedSignatureEvents: {
[signer: `npub1${string}`]: DocSignatureEvent
}
// Calculated completion time
completedAt?: number
// Calculated status fields
signedStatus: SigitStatus
signersStatus: {
@ -33,8 +66,8 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
const [isValid, setIsValid] = useState(false)
const [kind, setKind] = useState<number>()
const [tags, setTags] = useState<string[][]>()
const [created_at, setCreatedAt] = useState<number>()
const [pubkey, setPubkey] = useState<string>() // submittedBy, pubkey from nostr event
const [createdAt, setCreatedAt] = useState<number>()
const [submittedBy, setSubmittedBy] = useState<`npub1${string}`>() // submittedBy, pubkey from nostr event
const [id, setId] = useState<string>()
const [sig, setSig] = useState<string>()
@ -47,6 +80,12 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
const [title, setTitle] = 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>(
SigitStatus.Partial
)
@ -54,13 +93,13 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
[signer: `npub1${string}`]: SignStatus
}>({})
const [encryptionKey, setEncryptionKey] = useState<string | null>(null)
useEffect(() => {
if (!meta) return
;(async function () {
try {
const createSignatureEvent = await parseCreateSignatureEvent(
meta.createSignature
)
const createSignatureEvent = await parseNostrEvent(meta.createSignature)
const { kind, tags, created_at, pubkey, id, sig, content } =
createSignatureEvent
@ -70,7 +109,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
setTags(tags)
// created_at in nostr events are stored in seconds
setCreatedAt(fromUnixTimestamp(created_at))
setPubkey(pubkey)
setSubmittedBy(pubkey as `npub1${string}`)
setId(id)
setSig(sig)
@ -84,30 +123,108 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
setMarkConfig(markConfig)
setZipUrl(zipUrl)
// Parse each signature event and set signer status
if (meta.keys) {
const { sender, keys } = meta.keys
// Retrieve the user's public key from the state
const usersPubkey = (store.getState().auth as AuthState).usersPubkey!
const usersNpub = hexToNpub(usersPubkey)
// Check if the user's public key is in the keys object
if (usersNpub in keys) {
// Instantiate the NostrController to decrypt the encryption key
const nostrController = NostrController.getInstance()
const decrypted = await nostrController
.nip04Decrypt(sender, keys[usersNpub])
.catch((err) => {
console.log(
'An error occurred in decrypting encryption key',
err
)
return null
})
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 {
const event = await parseCreateSignatureEvent(
// Parse each signature event
const event = await parseNostrEvent(
meta.docSignatures[npub as `npub1${string}`]
)
const isValidSignature = verifyEvent(event)
setSignersStatus((prev) => {
return {
...prev,
[npub]: isValidSignature
? SignStatus.Signed
: SignStatus.Invalid
}
})
// 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) {
setSignersStatus((prev) => {
return {
...prev,
[npub]: SignStatus.Invalid
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 isCompletelySigned = signers.every((signer) =>
signedBy.includes(signer)
@ -115,6 +232,20 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
setSignedStatus(
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) {
if (error instanceof SigitMetaParseError) {
toast.error(error.message)
@ -125,15 +256,15 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
}, [meta])
return {
modifiedAt: meta.modifiedAt,
createSignature: meta.createSignature,
docSignatures: meta.docSignatures,
keys: meta.keys,
modifiedAt: meta?.modifiedAt,
createSignature: meta?.createSignature,
docSignatures: meta?.docSignatures,
keys: meta?.keys,
isValid,
kind,
tags,
created_at,
pubkey,
createdAt,
submittedBy,
id,
sig,
signers,
@ -142,7 +273,10 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
markConfig,
title,
zipUrl,
parsedSignatureEvents,
completedAt,
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 { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Outlet } from 'react-router-dom'
import { AppBar } from '../components/AppBar/AppBar'
@ -25,7 +25,6 @@ import {
subscribeForSigits
} from '../utils'
import { useAppSelector } from '../hooks'
import { SubCloser } from 'nostr-tools/abstract-pool'
import styles from './style.module.scss'
import { Footer } from '../components/Footer/Footer'
@ -36,6 +35,9 @@ export const MainLayout = () => {
const authState = useSelector((state: State) => state.auth)
const usersAppData = useAppSelector((state) => state.userAppData)
// Ref to track if `subscribeForSigits` has been called
const hasSubscribed = useRef(false)
useEffect(() => {
const metadataController = new MetadataController()
@ -103,21 +105,15 @@ export const MainLayout = () => {
}, [dispatch])
useEffect(() => {
let subCloser: SubCloser | null = null
if (authState.loggedIn && usersAppData) {
const pubkey = authState.usersPubkey || authState.keyPair?.public
if (pubkey) {
subscribeForSigits(pubkey).then((res) => {
subCloser = res || null
})
}
}
if (pubkey && !hasSubscribed.current) {
// Call `subscribeForSigits` only if it hasn't been called before
subscribeForSigits(pubkey)
return () => {
if (subCloser) {
subCloser.close()
// Mark `subscribeForSigits` as called
hasSubscribed.current = true
}
}
}, [authState, usersAppData])
@ -149,10 +145,19 @@ export const MainLayout = () => {
if (isLoading) return <LoadingSpinner desc={loadingSpinnerDesc} />
const isDev = import.meta.env.MODE === 'development'
return (
<>
<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 />
</main>
<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 {
Box,
Button,
FormControl,
IconButton,
InputLabel,
FormHelperText,
ListItemIcon,
ListItemText,
MenuItem,
Paper,
Select,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography
Tooltip
} from '@mui/material'
import type { Identifier, XYCoord } from 'dnd-core'
import saveAs from 'file-saver'
import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input'
import { Event, kinds } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react'
import { DndProvider, useDrag, useDrop } from 'react-dnd'
@ -61,14 +50,46 @@ import {
} from '../../utils'
import { Container } from '../../components/Container'
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 { 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 = () => {
const navigate = useNavigate()
const location = useLocation()
const { uploadedFiles } = location.state || {}
const [currentFile, setCurrentFile] = useState<File>()
const isActive = (file: File) => file.name === currentFile?.name
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
@ -76,9 +97,22 @@ export const CreatePage = () => {
const [authUrl, setAuthUrl] = useState<string>()
const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`)
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUploadButtonClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click()
}
}
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 [error, setError] = useState<string>()
@ -93,6 +127,132 @@ export const CreatePage = () => {
)
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(() => {
users.forEach((user) => {
if (!(user.pubkey in metadata)) {
@ -268,19 +428,22 @@ export const CreatePage = () => {
})
}
const handleSelectFiles = (files: File[]) => {
setSelectedFiles((prev) => {
const prevFileNames = prev.map((file) => file.name)
const newFiles = files.filter(
(file) => !prevFileNames.includes(file.name)
)
return [...prev, ...newFiles]
})
const handleSelectFiles = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
setSelectedFiles(Array.from(event.target.files))
}
}
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) =>
prevFiles.filter((file) => file.name !== fileToRemove.name)
)
@ -359,7 +522,8 @@ export const CreatePage = () => {
width: drawnField.width
},
npub: drawnField.counterpart,
pdfFileHash: fileHash
pdfFileHash: fileHash,
fileName: drawnPdf.file.name
}
})
})
@ -435,7 +599,7 @@ export const CreatePage = () => {
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'
})
}
@ -672,6 +836,7 @@ export const CreatePage = () => {
return
}
setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptZipFile(
arrayBuffer,
encryptionKey
@ -700,74 +865,140 @@ export const CreatePage = () => {
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<Container className={styles.container}>
<StickySideColumns
left={
<div className={styles.flexWrap}>
<div className={styles.inputWrapper}>
<TextField
label="Title"
placeholder="Title"
size="small"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
variant="outlined"
sx={{
width: '100%',
fontSize: '16px',
'& .MuiInputBase-input': {
padding: '7px 14px'
},
'& .MuiOutlinedInput-notchedOutline': {
display: 'none'
}
}}
/>
<Box>
<MuiFileInput
fullWidth
multiple
placeholder="Choose Files"
value={selectedFiles}
onChange={(value) => handleSelectFiles(value)}
/>
{selectedFiles.length > 0 && (
<ul>
{selectedFiles.map((file, index) => (
<li key={index}>
<Typography component="label">{file.name}</Typography>
<IconButton onClick={() => handleRemoveFile(file)}>
<Clear style={{ color: 'red' }} />{' '}
</IconButton>
</li>
</div>
<ol className={`${styles.paperGroup} ${styles.orderedFilesList}`}>
{selectedFiles.length > 0 &&
selectedFiles.map((file, index) => (
<div
key={index}
className={`${fileListStyles.fileItem} ${isActive(file) && fileListStyles.active}`}
onClick={() => {
handleFileClick('file-' + file.name)
setCurrentFile(file)
}}
>
<>
<span className={styles.fileName}>{file.name}</span>
<Button
variant="text"
onClick={(event) => handleRemoveFile(event, file)}
sx={{
minWidth: '44px'
}}
>
<FontAwesomeIcon icon={faTrash} />
</Button>
</>
</div>
))}
</ul>
)}
</Box>
<Typography component="label" variant="h6">
Add Counterparts
</Typography>
<Box className={styles.inputBlock}>
<Box className={styles.inputBlock}>
</ol>
<Button variant="contained" onClick={handleUploadButtonClick}>
<FontAwesomeIcon icon={faUpload} />
<span className={styles.uploadFileText}>Upload new files</span>
<input
ref={fileInputRef}
hidden={true}
multiple={true}
type="file"
onChange={handleSelectFiles}
/>
</Button>
</div>
}
right={
<div className={styles.flexWrap}>
<div className={styles.inputWrapper}>
<TextField
label="nip05 / npub"
placeholder="User (nip05 / npub)"
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
helperText={error}
onKeyDown={handleInputKeyDown}
error={!!error}
fullWidth
sx={{
fontSize: '16px',
'& .MuiInputBase-input': {
padding: '7px 14px'
},
'& .MuiOutlinedInput-notchedOutline': {
display: 'none'
}
}}
/>
<FormControl fullWidth>
<InputLabel id="select-role-label">Role</InputLabel>
<Select
labelId="select-role-label"
id="demo-simple-select"
name="add-user-role"
aria-label="role"
value={userRole}
label="Role"
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}>{UserRole.signer}</MenuItem>
<MenuItem value={UserRole.viewer}>{UserRole.viewer}</MenuItem>
<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>
</FormControl>
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button
disabled={!userInput}
onClick={handleAddUser}
variant="contained"
aria-label="Add"
sx={{
minWidth: '44px',
padding: '11.5px 12px',
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0
}}
>
Add
<FontAwesomeIcon icon={faPlus} />
</Button>
</Box>
</Box>
</Box>
</div>
<div className={styles.paperGroup}>
<DisplayUser
metadata={metadata}
users={users}
@ -775,19 +1006,59 @@ export const CreatePage = () => {
handleRemoveUser={handleRemoveUser}
moveSigner={moveSigner}
/>
</div>
<Button onClick={handleCreate} variant="contained">
Publish
</Button>
<div className={`${styles.paperGroup} ${styles.toolbox}`}>
{toolbox.map((drawTool: DrawTool, index: number) => {
return (
<div
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}
/>
<Box sx={{ mt: 1, mb: 5, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleCreate} variant="contained">
Create
</Button>
</Box>
</StickySideColumns>
</Container>
</>
)
@ -809,16 +1080,7 @@ const DisplayUser = ({
moveSigner
}: DisplayUsersProps) => {
return (
<TableContainer component={Paper} elevation={3} sx={{ marginTop: '20px' }}>
<Table>
<TableHead>
<TableRow>
<TableCell className={styles.tableHeaderCell}>User</TableCell>
<TableCell className={styles.tableHeaderCell}>Role</TableCell>
<TableCell>Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
<>
<DndProvider backend={HTML5Backend}>
{users
.filter((user) => user.role === UserRole.signer)
@ -839,8 +1101,8 @@ const DisplayUser = ({
.map((user, index) => {
const userMeta = metadata[user.pubkey]
return (
<TableRow key={index}>
<TableCell className={styles.tableCell}>
<div className={styles.user} key={index}>
<div className={styles.avatar}>
<UserAvatar
pubkey={user.pubkey}
name={
@ -850,39 +1112,61 @@ const DisplayUser = ({
}
image={userMeta?.picture}
/>
</TableCell>
<TableCell className={styles.tableCell}>
</div>
<Select
fullWidth
name={`change-user-role-${user.pubkey}`}
aria-label="role"
value={user.role}
variant="outlined"
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
)
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>
<MenuItem value={UserRole.signer}>{UserRole.signer}</MenuItem>
<MenuItem value={UserRole.viewer}>{UserRole.viewer}</MenuItem>
</Select>
</TableCell>
<TableCell>
<Tooltip title="Remove User" arrow>
<IconButton onClick={() => handleRemoveUser(user.pubkey)}>
<Clear style={{ color: 'red' }} />
</IconButton>
<Button
onClick={() => handleRemoveUser(user.pubkey)}
sx={{
minWidth: '34px',
height: '34px',
padding: 0,
color: 'rgba(0, 0, 0, 0.35)',
'&:hover': {
color: 'white'
}
}}
>
<FontAwesomeIcon fontSize={'14px'} icon={faTrash} />
</Button>
</Tooltip>
</TableCell>
</TableRow>
</div>
)
})}
</TableBody>
</Table>
</TableContainer>
</>
)
}
@ -986,16 +1270,14 @@ const SignerRow = ({
drag(drop(ref))
return (
<TableRow
sx={{ cursor: 'move', opacity }}
<div
className={styles.user}
style={{ cursor: 'move', opacity }}
data-handler-id={handlerId}
ref={ref}
>
<TableCell
className={styles.tableCell}
sx={{ display: 'flex', alignItems: 'center', gap: '10px' }}
>
<DragHandle />
<FontAwesomeIcon width={'14px'} fontSize={'14px'} icon={faGripLines} />
<div className={styles.avatar}>
<UserAvatar
pubkey={user.pubkey}
name={
@ -1005,26 +1287,57 @@ const SignerRow = ({
}
image={userMeta?.picture}
/>
</TableCell>
<TableCell className={styles.tableCell}>
</div>
<Select
fullWidth
name={`change-user-role-${user.pubkey}`}
aria-label="role"
value={user.role}
variant="outlined"
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>
</TableCell>
<TableCell>
<Tooltip title="Remove User" arrow>
<IconButton onClick={() => handleRemoveUser(user.pubkey)}>
<Clear style={{ color: 'red' }} />
</IconButton>
<Button
onClick={() => handleRemoveUser(user.pubkey)}
sx={{
minWidth: '34px',
height: '34px',
padding: 0,
color: 'rgba(0, 0, 0, 0.35)',
'&:hover': {
color: 'white'
}
}}
>
<FontAwesomeIcon fontSize={'14px'} icon={faTrash} />
</Button>
</Tooltip>
</TableCell>
</TableRow>
</div>
)
}

View File

@ -1,41 +1,176 @@
@import '../../styles/colors.scss';
.container {
.flexWrap {
display: flex;
flex-direction: column;
color: $text-color;
margin-top: 10px;
gap: 15px;
}
.orderedFilesList {
counter-reset: item;
list-style-type: none;
margin: 0;
li {
display: flex;
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;
width: 550px;
max-width: 550px;
.inputBlock {
&::before {
content: counter(item) ' ';
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: 25px;
}
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;
}
.subHeader {
border-bottom: 0.5px solid;
}
.inputWrapper {
display: flex;
align-items: center;
.tableHeaderCell {
border-right: 1px solid rgba(224, 224, 224, 1);
}
height: 34px;
overflow: hidden;
border-radius: 4px;
outline: solid 1px #dddddd;
background: white;
.tableCell {
border-right: 1px solid rgba(224, 224, 224, 1);
height: 56px;
width: 100%;
&:focus-within {
outline-color: $primary-main;
}
}
.user {
display: flex;
align-items: center;
gap: 10px;
.name {
text-align: center;
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"
aria-label="upload files"
>
<input {...getInputProps()} />
<input id="file-upload" {...getInputProps()} />
{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>
<div className={styles.submissions}>

View File

@ -12,7 +12,12 @@ import { MetadataController } from '../../controllers'
import { getProfileSettingsRoute } from '../../routes'
import { State } from '../../store/rootReducer'
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 { Container } from '../../components/Container'
@ -51,8 +56,7 @@ export const ProfilePage = () => {
useEffect(() => {
if (pubkey) {
metadataController
.getNostrJoiningBlockNumber(pubkey)
getNostrJoiningBlockNumber(pubkey)
.then((res) => {
setNostrJoiningBlock(res)
})

View File

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

View File

@ -12,138 +12,44 @@ import ListItemText from '@mui/material/ListItemText'
import Switch from '@mui/material/Switch'
import { useEffect, useState } from 'react'
import { toast } from 'react-toastify'
import { NostrController } from '../../../controllers'
import { useAppDispatch, useAppSelector } from '../../../hooks'
import {
setRelayMapAction,
setRelayMapUpdatedAction
} from '../../../store/actions'
import {
RelayConnectionState,
RelayFee,
RelayInfoObject,
RelayMap
} from '../../../types'
import { Container } from '../../../components/Container'
import { relayController } from '../../../controllers'
import { useAppDispatch, useAppSelector, useDidMount } from '../../../hooks'
import { setRelayMapAction } from '../../../store/actions'
import { RelayConnectionState, RelayFee, RelayInfo } from '../../../types'
import {
capitalizeFirstLetter,
compareObjects,
getRelayInfo,
getRelayMap,
hexToNpub,
publishRelayMap,
shorten
} from '../../../utils'
import styles from './style.module.scss'
import { Container } from '../../../components/Container'
export const RelaysPage = () => {
const nostrController = NostrController.getInstance()
const relaysState = useAppSelector((state) => state.relays)
const usersPubkey = useAppSelector((state) => state.auth?.usersPubkey)
const dispatch = useAppDispatch()
const [newRelayURI, setNewRelayURI] = useState<string>()
const [newRelayURIerror, setNewRelayURIerror] = useState<string>()
const [relayMap, setRelayMap] = useState<RelayMap | undefined>(
relaysState?.map
)
const [relaysInfo, setRelaysInfo] = useState<RelayInfoObject | undefined>(
relaysState?.info
)
const [displayRelaysInfo, setDisplayRelaysInfo] = useState<string[]>([])
const [relaysConnectionStatus, setRelaysConnectionStatus] = useState(
relaysState?.connectionStatus
)
const relayMap = useAppSelector((state) => state.relays?.map)
const relaysInfo = useAppSelector((state) => state.relays?.info)
const webSocketPrefix = 'wss://'
// Update relay connection status
useEffect(() => {
if (
!compareObjects(relaysConnectionStatus, relaysState?.connectionStatus)
) {
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 () => {
useDidMount(() => {
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)
getRelayMap(usersPubkey).then((newRelayMap) => {
if (!compareObjects(relayMap, 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(() => {
// Display notification if an empty relay map has been received
@ -175,24 +81,23 @@ export const RelaysPage = () => {
if (usersPubkey) {
// Publish updated relay map.
const relayMapPublishingRes = await nostrController
.publishRelayMap(relayMapCopy, usersPubkey, [relay])
.catch((err) => handlePublishRelayMapError(err))
const relayMapPublishingRes = await publishRelayMap(
relayMapCopy,
usersPubkey,
[relay]
).catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) {
toast.success(relayMapPublishingRes)
setRelayMap(relayMapCopy)
dispatch(setRelayMapAction(relayMapCopy))
}
}
nostrController.disconnectFromRelays([relay])
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlePublishRelayMapError = (err: any) => {
const errorPrefix = 'Error while publishing Relay Map'
@ -224,15 +129,14 @@ export const RelaysPage = () => {
if (usersPubkey) {
// Publish updated relay map
const relayMapPublishingRes = await nostrController
.publishRelayMap(relayMapCopy, usersPubkey)
.catch((err) => handlePublishRelayMapError(err))
const relayMapPublishingRes = await publishRelayMap(
relayMapCopy,
usersPubkey
).catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) {
toast.success(relayMapPublishingRes)
setRelayMap(relayMapCopy)
dispatch(setRelayMapAction(relayMapCopy))
}
}
@ -256,29 +160,25 @@ export const RelaysPage = () => {
)
}
} else if (relayURI && usersPubkey) {
const connectionStatus = await nostrController.connectToRelays([relayURI])
const relay = await relayController.connectRelay(relayURI)
if (
connectionStatus &&
connectionStatus[relayURI] &&
connectionStatus[relayURI] === RelayConnectionState.Connected
) {
if (relay && relay.connected) {
const relayMapCopy = JSON.parse(JSON.stringify(relayMap))
relayMapCopy[relayURI] = { write: true, read: true }
// Publish updated relay map
const relayMapPublishingRes = await nostrController
.publishRelayMap(relayMapCopy, usersPubkey)
.catch((err) => handlePublishRelayMapError(err))
const relayMapPublishingRes = await publishRelayMap(
relayMapCopy,
usersPubkey
).catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) {
setRelayMap(relayMapCopy)
setNewRelayURI('')
dispatch(setRelayMapAction(relayMapCopy))
nostrController.getRelayInfo([relayURI])
getRelayInfo([relayURI])
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 (
<Container className={styles.container}>
<Box className={styles.relayAddContainer}>
@ -343,39 +220,86 @@ export const RelaysPage = () => {
</Box>
{relayMap && (
<Box className={styles.relaysContainer}>
{Object.keys(relayMap).map((relay, i) => (
<Box className={styles.relay} key={`relay_${i}`}>
{Object.keys(relayMap).map((relay) => (
<RelayItem
key={relay}
relayURI={relay}
isWriteRelay={relayMap[relay].write}
relayInfo={relaysInfo ? relaysInfo[relay] : undefined}
handleLeaveRelay={handleLeaveRelay}
handleRelayWriteChange={handleRelayWriteChange}
/>
))}
</Box>
)}
</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,
relaysConnectionStatus
? relaysConnectionStatus[relay] ===
RelayConnectionState.Connected
relayConnectionStatus
? relayConnectionStatus === RelayConnectionState.Connected
? styles.connectionStatusConnected
: styles.connectionStatusNotConnected
: styles.connectionStatusUnknown
].join(' ')}
/>
{relaysInfo &&
relaysInfo[relay] &&
relaysInfo[relay].limitation &&
relaysInfo[relay].limitation?.payment_required && (
{relayInfo &&
relayInfo.limitation &&
relayInfo.limitation?.payment_required && (
<Tooltip title="Paid Relay" arrow placement="top">
<ElectricBoltIcon
className={styles.lightningIcon}
color="warning"
onClick={() => handleRelayInfo(relay)}
onClick={() => setDisplayRelayInfo((prev) => !prev)}
/>
</Tooltip>
)}
<ListItemText primary={relay} />
<ListItemText primary={relayURI} />
<Box
className={styles.leaveRelayContainer}
onClick={() => handleLeaveRelay(relay)}
onClick={() => handleLeaveRelay(relayURI)}
>
<LogoutIcon />
<span>Leave</span>
@ -386,20 +310,16 @@ export const RelaysPage = () => {
<ListItemText
primary="Publish to this relay?"
secondary={
relaysInfo && relaysInfo[relay] ? (
relayInfo ? (
<span
onClick={() => handleRelayInfo(relay)}
onClick={() => setDisplayRelayInfo((prev) => !prev)}
className={styles.showInfo}
>
Show info{' '}
{displayRelaysInfo.includes(relay) ? (
<KeyboardArrowUpIcon
className={styles.showInfoIcon}
/>
{displayRelayInfo ? (
<KeyboardArrowUpIcon className={styles.showInfoIcon} />
) : (
<KeyboardArrowDownIcon
className={styles.showInfoIcon}
/>
<KeyboardArrowDownIcon className={styles.showInfoIcon} />
)}
</span>
) : (
@ -408,22 +328,22 @@ export const RelaysPage = () => {
}
/>
<Switch
checked={relayMap[relay].write}
onChange={(event) => handleRelayWriteChange(relay, event)}
checked={isWriteRelay}
onChange={(event) => handleRelayWriteChange(relayURI, event)}
/>
</ListItem>
{displayRelaysInfo.includes(relay) && (
{displayRelayInfo && (
<>
<Divider className={styles.relayDivider} />
<ListItem>
<Box className={styles.relayInfoContainer}>
{relaysInfo &&
relaysInfo[relay] &&
Object.keys(relaysInfo[relay]).map((key: string) => {
{relayInfo &&
Object.keys(relayInfo).map((key: string) => {
const infoTitle = capitalizeFirstLetter(
key.replace('_', ' ')
)
let infoValue = (relaysInfo[relay] as any)[key]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let infoValue = (relayInfo as any)[key]
switch (key) {
case 'pubkey':
@ -433,12 +353,10 @@ export const RelaysPage = () => {
case 'limitation':
infoValue = (
<ul key={`${i}_${key}`}>
<ul>
{Object.keys(infoValue).map((valueKey) => (
<li key={`${i}_${key}_${valueKey}`}>
<span
className={styles.relayInfoSubTitle}
>
<li key={`${relayURI}_${key}_${valueKey}`}>
<span className={styles.relayInfoSubTitle}>
{capitalizeFirstLetter(
valueKey.split('_').join(' ')
)}
@ -456,10 +374,8 @@ export const RelaysPage = () => {
infoValue = (
<ul>
{Object.keys(infoValue).map((valueKey) => (
<li key={`${i}_${key}_${valueKey}`}>
<span
className={styles.relayInfoSubTitle}
>
<li key={`${relayURI}_${key}_${valueKey}`}>
<span className={styles.relayInfoSubTitle}>
{capitalizeFirstLetter(
valueKey.split('_').join(' ')
)}
@ -480,7 +396,7 @@ export const RelaysPage = () => {
}
return (
<span key={`${i}_${key}_container`}>
<span key={`${relayURI}_${key}_container`}>
<span className={styles.relayInfoTitle}>
{infoTitle}:
</span>{' '}
@ -490,9 +406,8 @@ export const RelaysPage = () => {
className={styles.copyItem}
onClick={() => {
navigator.clipboard.writeText(
hexToNpub(
(relaysInfo[relay] as any)[key]
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hexToNpub((relayInfo as any)[key])
)
toast.success('Copied to clipboard', {
@ -511,9 +426,5 @@ export const RelaysPage = () => {
)}
</List>
</Box>
))}
</Box>
)}
</Container>
)
}

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,
generateEncryptionKey,
generateKeysFile,
getFilesWithHashes,
getCurrentUserFiles,
getHash,
hexToNpub,
isOnline,
@ -48,6 +48,8 @@ import {
updateMarks
} from '../../utils'
import PdfMarking from '../../components/PDFView/PdfMarking.tsx'
import { getZipWithFiles } from '../../utils/file.ts'
import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts'
enum SignedStatus {
Fully_Signed,
User_Is_Next_Signer,
@ -219,6 +221,29 @@ export const SignPage = () => {
}
}, [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(
async (file: File) => {
setLoadingSpinnerDesc('Decrypting file')
@ -929,11 +954,13 @@ export const SignPage = () => {
return (
<PdfMarking
files={getFilesWithHashes(files, currentFileHashes)}
files={getCurrentUserFiles(files, currentFileHashes, creatorFileHashes)}
currentUserMarks={currentUserMarks}
setIsReadyToSign={setIsReadyToSign}
setCurrentUserMarks={setCurrentUserMarks}
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 arrayBuffer = await files[filename].file.arrayBuffer()

View File

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

View File

@ -1,42 +1,29 @@
import {
Box,
Button,
List,
ListItem,
ListSubheader,
Tooltip,
Typography,
useTheme
} from '@mui/material'
import { Box, Button, Divider, Tooltip, Typography } from '@mui/material'
import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input'
import { Event, kinds, verifyEvent } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { Event, verifyEvent } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { UserAvatar } from '../../components/UserAvatar'
import { MetadataController, NostrController } from '../../controllers'
import { NostrController } from '../../controllers'
import {
CreateSignatureEventContent,
Meta,
ProfileMetadata,
SignedEventContent
DocSignatureEvent,
Meta
} from '../../types'
import {
decryptArrayBuffer,
extractMarksFromSignedMeta,
extractZipUrlAndEncryptionKey,
getHash,
hexToNpub,
unixNow,
npubToHex,
parseJson,
readContentOfZipEntry,
signEventForMetaFile,
shorten,
signEventForMetaFile
getCurrentUserFiles
} from '../../utils'
import styles from './style.module.scss'
import { Cancel, CheckCircle } from '@mui/icons-material'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import { PdfFile } from '../../types/drawing.ts'
@ -44,71 +31,172 @@ import {
addMarks,
convertToPdfBlob,
convertToPdfFile,
groupMarksByPage
groupMarksByFileNamePage,
inPx
} from '../../utils/pdf.ts'
import { State } from '../../store/rootReducer.ts'
import { useSelector } from 'react-redux'
import { getLastSignersSig } from '../../utils/sign.ts'
import { saveAs } from 'file-saver'
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 = () => {
const theme = useTheme()
const textColor = theme.palette.getContrastText(
theme.palette.background.paper
)
const location = useLocation()
/**
* 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
*/
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 [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
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<{
[key: string]: string | null
}>({})
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 nostrController = NostrController.getInstance()
useEffect(() => {
if (uploadedZip) {
setSelectedFile(uploadedZip)
} else if (metaInNavState) {
} else if (meta && encryptionKey) {
const processSigit = async () => {
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')
axios
@ -175,12 +263,6 @@ export const VerifyPage = () => {
setCurrentFileHashes(fileHashes)
setFiles(files)
setSigners(createSignatureContent.signers)
setViewers(createSignatureContent.viewers)
setCreatorFileHashes(createSignatureContent.fileHashes)
setSubmittedBy(createSignatureEvent.pubkey)
setMeta(metaInNavState)
setIsLoading(false)
}
})
@ -197,49 +279,7 @@ export const VerifyPage = () => {
processSigit()
}
}, [uploadedZip, metaInNavState])
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])
}, [encryptionKey, meta, uploadedZip, zipUrl])
const handleVerify = async () => {
if (!selectedFile) return
@ -278,7 +318,6 @@ export const VerifyPage = () => {
}
}
console.log('fileHashes :>> ', fileHashes)
setCurrentFileHashes(fileHashes)
setLoadingSpinnerDesc('Parsing meta.json')
@ -345,44 +384,9 @@ export const VerifyPage = () => {
if (!createSignatureContent) return
setSigners(createSignatureContent.signers)
setViewers(createSignatureContent.viewers)
setCreatorFileHashes(createSignatureContent.fileHashes)
setSubmittedBy(createSignatureEvent.pubkey)
setMeta(parsedMetaJson)
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 () => {
if (Object.entries(files).length === 0 || !meta || !usersPubkey) return
@ -398,8 +402,6 @@ export const VerifyPage = () => {
setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event')
if (!meta) return
const prevSig = getLastSignersSig(meta, signers)
if (!prevSig) return
@ -419,12 +421,12 @@ export const VerifyPage = () => {
zip.file('meta.json', stringifiedMeta)
const marks = extractMarksFromSignedMeta(updatedMeta)
const marksByPage = groupMarksByPage(marks)
const marksByPage = groupMarksByFileNamePage(marks)
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)
zip.file(`/files/${fileName}`, blob)
zip.file(`files/${fileName}`, blob)
}
const arrayBuffer = await zip
@ -450,76 +452,6 @@ export const VerifyPage = () => {
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 = () => {
if (!meta || !meta.exportSignature) return null
@ -529,7 +461,24 @@ export const VerifyPage = () => {
const exportSignatureEvent = JSON.parse(exportSignatureString) as Event
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 {
toast.error(`Invalid export signature!`)
return (
@ -576,147 +525,36 @@ export const VerifyPage = () => {
)}
{meta && (
<StickySideColumns
left={
<>
<List
sx={{
bgcolor: 'background.paper',
marginTop: 2
}}
subheader={
<ListSubheader className={styles.subHeader}>
Meta Info
</ListSubheader>
}
>
{submittedBy && (
<ListItem
sx={{
marginTop: 1,
gap: '15px'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Submitted By
</Typography>
{displayUser(submittedBy)}
</ListItem>
{currentFile !== null && (
<FileList
files={getCurrentUserFiles(
files,
currentFileHashes,
signatureFileHashes
)}
currentFile={currentFile}
setCurrentFile={setCurrentFile}
handleDownload={handleExport}
/>
)}
<ListItem
sx={{
marginTop: 1,
gap: '15px'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Exported By
</Typography>
{displayExportedBy()}
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleExport} variant="contained">
Export Sigit
</Button>
</Box>
</ListItem>
{signers.length > 0 && (
<ListItem
sx={{
marginTop: 1,
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Signers
</Typography>
<ul className={styles.usersList}>
{signers.map((signer) => (
<li
key={signer}
style={{
color: textColor,
display: 'flex',
alignItems: 'center',
gap: '15px'
}}
>
{displayUser(npubToHex(signer)!, true)}
</li>
))}
</ul>
</ListItem>
)}
{viewers.length > 0 && (
<ListItem
sx={{
marginTop: 1,
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Viewers
</Typography>
<ul className={styles.usersList}>
{viewers.map((viewer) => (
<li key={viewer} style={{ color: textColor }}>
{displayUser(npubToHex(viewer)!)}
</li>
))}
</ul>
</ListItem>
)}
<ListItem
sx={{
marginTop: 1,
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Files
</Typography>
<Box className={styles.filesWrapper}>
{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>
</>
}
right={<UsersDetails meta={meta} />}
>
<SlimPdfView
currentFile={currentFile}
files={getCurrentUserFiles(
files,
currentFileHashes,
signatureFileHashes
)}
parsedSignatureEvents={parsedSignatureEvents}
/>
</StickySideColumns>
)}
</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_MAP_UPDATED = 'SET_RELAY_MAP_UPDATED'
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_PROCESSED_GIFT_WRAPS = 'UPDATE_PROCESSED_GIFT_WRAPS'

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { Mark } from './mark'
import { Keys } from '../store/auth/types'
import { Event } from 'nostr-tools'
export enum UserRole {
signer = 'Signer',
@ -44,3 +45,7 @@ export interface UserAppData {
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
}
export interface DocSignatureEvent extends Event {
parsedContent?: SignedEventContent
}

View File

@ -41,9 +41,22 @@ export interface DrawTool {
}
export enum MarkType {
TEXT = 'TEXT',
SIGNATURE = 'SIGNATURE',
JOBTITLE = 'JOBTITLE',
FULLNAME = 'FULLNAME',
INITIALS = 'INITIALS',
DATETIME = 'DATETIME',
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,9 +1,11 @@
export class DecryptionError extends Error {
public message: string = ''
constructor(public inputError: any) {
constructor(public inputError: unknown) {
super()
// Make sure inputError has access to the .message
if (inputError instanceof Error) {
if (inputError.message.toLowerCase().includes('expected')) {
this.message = `The decryption key length or format is invalid.`
} else if (
@ -14,6 +16,12 @@ export class DecryptionError extends Error {
this.message =
inputError.message || 'An error occurred while decrypting file.'
}
} else {
// We don't have message on the inputError
// Stringify whole error and set that as a message
this.message =
JSON.stringify(inputError) || 'An error occurred while decrypting file.'
}
this.name = 'DecryptionError'
Object.setPrototypeOf(this, DecryptionError.prototype)

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'
export interface CurrentUserMark {
id: number
mark: Mark
isLast: boolean
isCompleted: boolean
currentValue?: string
}
export interface Mark {
@ -12,6 +14,7 @@ export interface Mark {
pdfFileHash: string
type: MarkType
location: MarkLocation
fileName: string
value?: string
}

View File

@ -4,6 +4,11 @@ export const EMPTY: string = ''
export const MARK_TYPE_TRANSLATION: { [key: string]: string } = {
[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.
* 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 './dvm'
export * from './hash'
export * from './localStorage'
export * from './misc'
export * from './nostr'
export * from './string'
export * from './zip'
export * from './utils'
export * from './mark'
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 { Meta, SignedEventContent } from '../types'
import { Event } from 'nostr-tools'
import { EMPTY } from './const.ts'
/**
* Takes in an array of Marks already filtered by User.
@ -15,13 +16,12 @@ const getCurrentUserMarks = (
): CurrentUserMark[] => {
return marks.map((mark, index, arr) => {
const signedMark = signedMetaMarks.find((m) => m.id === mark.id)
if (signedMark && !!signedMark.value) {
mark.value = signedMark.value
}
return {
mark,
currentValue: signedMark?.value ?? EMPTY,
id: index + 1,
isLast: isLast(index, arr),
isCompleted: !!mark.value
isCompleted: !!signedMark?.value
}
})
}
@ -30,7 +30,7 @@ const getCurrentUserMarks = (
* Returns next incomplete CurrentUserMark if there is one
* @param usersMarks
*/
const findNextCurrentUserMark = (
const findNextIncompleteCurrentUserMark = (
usersMarks: CurrentUserMark[]
): CurrentUserMark | undefined => {
return usersMarks.find((mark) => !mark.isCompleted)
@ -99,12 +99,42 @@ const updateCurrentUserMarks = (
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 {
getCurrentUserMarks,
filterMarksByPubkey,
extractMarksFromSignedMeta,
isCurrentUserMarksComplete,
findNextCurrentUserMark,
findNextIncompleteCurrentUserMark,
updateMarks,
updateCurrentUserMarks
updateCurrentUserMarks,
isCurrentValueLast,
getUpdatedMark
}

View File

@ -1,12 +1,14 @@
import { CreateSignatureEventContent, Meta } from '../types'
import { fromUnixTimestamp, parseJson } from '.'
import { Event } from 'nostr-tools'
import { Event, verifyEvent } from 'nostr-tools'
import { toast } from 'react-toastify'
export enum SignStatus {
Signed = 'Signed',
Awaiting = 'Awaiting',
Pending = 'Pending',
Invalid = 'Invalid Sign'
Invalid = 'Invalid',
Viewer = 'Viewer'
}
export enum SigitStatus {
@ -62,38 +64,34 @@ function handleError(error: unknown): Error {
// Reuse common error messages for meta parsing
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"
}
export interface SigitCardDisplayInfo {
createdAt?: number
title?: string
submittedBy?: string
submittedBy?: `npub1${string}`
signers: `npub1${string}`[]
fileExtensions: string[]
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
* @returns parsed Event
*/
export const parseCreateSignatureEvent = async (
raw: string
): Promise<Event> => {
export const parseNostrEvent = async (raw: string): Promise<Event> => {
try {
const createSignatureEvent = await parseJson<Event>(raw)
return createSignatureEvent
const event = await parseJson<Event>(raw)
return event
} catch (error) {
throw new SigitMetaParseError(
SigitMetaParseErrorType.PARSE_ERROR_SIGNATURE_EVENT,
{
throw new SigitMetaParseError(SigitMetaParseErrorType.PARSE_ERROR_EVENT, {
cause: handleError(error),
context: raw
}
)
})
}
}
@ -131,13 +129,14 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => {
const sigitInfo: SigitCardDisplayInfo = {
signers: [],
fileExtensions: [],
signedStatus: SigitStatus.Partial
signedStatus: SigitStatus.Partial,
isValid: false
}
try {
const createSignatureEvent = await parseCreateSignatureEvent(
meta.createSignature
)
const createSignatureEvent = await parseNostrEvent(meta.createSignature)
sigitInfo.isValid = verifyEvent(createSignatureEvent)
// created_at in nostr events are stored in seconds
sigitInfo.createdAt = fromUnixTimestamp(createSignatureEvent.created_at)
@ -147,13 +146,7 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => {
)
const files = Object.keys(createSignatureContent.fileHashes)
const extensions = files.reduce((result: string[], file: string) => {
const extension = file.split('.').pop()
if (extension) {
result.push(extension)
}
return result
}, [])
const { extensions } = extractFileExtensions(files)
const signedBy = Object.keys(meta.docSignatures) as `npub1${string}`[]
const isCompletelySigned = createSignatureContent.signers.every((signer) =>
@ -161,7 +154,7 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => {
)
sigitInfo.title = createSignatureContent.title
sigitInfo.submittedBy = createSignatureEvent.pubkey
sigitInfo.submittedBy = createSignatureEvent.pubkey as `npub1${string}`
sigitInfo.signers = createSignatureContent.signers
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,
EventTemplate,
Filter,
SimplePool,
UnsignedEvent,
finalizeEvent,
generateSecretKey,
@ -18,7 +17,11 @@ import {
} from 'nostr-tools'
import { toast } from 'react-toastify'
import { NIP05_REGEX } from '../constants'
import { MetadataController, NostrController } from '../controllers'
import {
MetadataController,
NostrController,
relayController
} from '../controllers'
import {
updateProcessedGiftWraps,
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> => {
// Initialize an array to hold relay URLs
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 relayMap = store.getState().relays?.map
const nostrController = NostrController.getInstance()
// check if relaysMap in redux store is undefined
// Check if relayMap is undefined in the Redux store
if (!relayMap) {
// If relayMap is not present, fetch relay list metadata
const metadataController = new MetadataController()
const relaySet = await metadataController
.findRelayListMetadata(usersPubkey)
.catch((err) => {
// Log error and return null if fetching metadata fails
console.log(
`An error occurred while finding relay list metadata for ${hexToNpub(usersPubkey)}`,
err
@ -349,41 +359,42 @@ export const getUsersAppData = async (): Promise<UserAppData | null> => {
return null
})
// Return if metadata retrieval failed
// Return null if metadata retrieval failed
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
// Add write relays to the relays array
relays.push(...relaySet.write)
} 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(
(key) => relayMap[key].write
)
relays.push(...writeRelays)
}
// generate an identifier for user's nip78
// Generate an identifier for the user's nip78
const hash = await getHash('938' + usersPubkey)
if (!hash) return null
// Define a filter for fetching events
const filter: Filter = {
kinds: [kinds.Application],
'#d': [hash]
}
const encryptedContent = await nostrController
.getEvent(filter, relays)
const encryptedContent = await relayController
.fetchEvent(filter, relays)
.then((event) => {
if (event) return event.content
// if person is using sigit for first time its possible that event is null
// so we'll return empty stringified object
// If no event is found, return an empty stringified object
return '{}'
})
.catch((err) => {
// Log error and show a toast notification if fetching event fails
console.log(`An error occurred in finding kind 30078 event`, err)
toast.error(
'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 if encrypted content retrieval fails
if (!encryptedContent) return null
// Handle case where the encrypted content is an empty object
if (encryptedContent === '{}') {
const secret = generateSecretKey()
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
.nip04Decrypt(usersPubkey, encryptedContent)
.catch((err) => {
// Log error and show a toast notification if decryption fails
console.log('An error occurred while decrypting app data', err)
toast.error('An error occurred while decrypting app data')
return null
})
// Return null if decryption fails
if (!decrypted) return null
// Parse the decrypted content
const parsedContent = await parseJson<{
blossomUrls: string[]
keyPair: Keys
}>(decrypted).catch((err) => {
// Log error and show a toast notification if parsing fails
console.log(
'An error occurred in parsing the content of kind 30078 event',
err
@ -430,21 +451,26 @@ export const getUsersAppData = async (): Promise<UserAppData | null> => {
return null
})
// Return null if parsing fails
if (!parsedContent) return null
const { blossomUrls, keyPair } = parsedContent
// Return null if no blossom URLs are found
if (blossomUrls.length === 0) return null
// Fetch additional user app data from the first blossom URL
const dataFromBlossom = await getUserAppDataFromBlossom(
blossomUrls[0],
keyPair.private
)
// Return null if fetching data from blossom fails
if (!dataFromBlossom) return null
const { sigits, processedGiftWraps } = dataFromBlossom
// Return the final user application data
return {
blossomUrls,
keyPair,
@ -575,10 +601,9 @@ export const updateUsersAppData = async (meta: Meta) => {
const relayMap = (store.getState().relays as RelaysState).map!
const writeRelays = Object.keys(relayMap).filter((key) => relayMap[key].write)
console.log(`publishing event kind: ${kinds.Application}`)
const publishResult = await Promise.race([
nostrController.publishEvent(signedEvent, writeRelays),
timeout(1000 * 30)
relayController.publish(signedEvent, writeRelays),
timeout(40 * 1000)
]).catch((err) => {
console.log('err :>> ', err)
if (err.message === 'Timeout') {
@ -817,15 +842,8 @@ export const subscribeForSigits = async (pubkey: string) => {
'#p': [pubkey]
}
// Instantiate a new SimplePool for the subscription
const pool = new SimplePool()
// 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) => {
relayController.subscribeForEvents(filter, relaySet.read, (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
if (relaySet.read.length === 0) return
console.log('Publishing notifications')
// 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([
nostrController.publishEvent(wrappedEvent, relaySet.read),
timeout(1000 * 30)
relayController.publish(wrappedEvent, relaySet.read),
timeout(40 * 1000)
]).catch((err) => {
// Log an error if publishing the notification event fails
console.log(

View File

@ -71,14 +71,19 @@ const isPdf = (file: File) => file.type.toLowerCase().includes('pdf')
/**
* Reads the pdf file binaries
*/
const readPdf = (file: File): Promise<string> => {
const readPdf = (file: File): Promise<string | ArrayBuffer> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e: any) => {
const data = e.target.result
reader.onload = (e) => {
const data = e.target?.result
// Make sure we only resolve for string or ArrayBuffer type
// 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) => {
@ -94,7 +99,7 @@ const readPdf = (file: File): Promise<string> => {
* Converts pdf to the images
* @param data pdf file bytes
*/
const pdfToImages = async (data: any): Promise<PdfPage[]> => {
const pdfToImages = async (data: string | ArrayBuffer): Promise<PdfPage[]> => {
const images: string[] = []
const pdf = await PDFJS.getDocument(data).promise
const canvas = document.createElement('canvas')
@ -142,7 +147,8 @@ const addMarks = async (
canvas.width = viewport.width
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())
}
@ -230,11 +236,11 @@ const convertToPdfFile = async (
* @function scaleMark scales remaining marks in line with SCALE
* @function byPage groups remaining Marks by their page marks.location.page
*/
const groupMarksByPage = (marks: Mark[]) => {
const groupMarksByFileNamePage = (marks: Mark[]) => {
return marks
.filter(hasValue)
.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 mark - current value, i.e. Mark being examined
*/
const byPage = (obj: { [key: number]: Mark[] }, mark: Mark) => {
const key = mark.location.page
const curGroup = obj[key] ?? []
return { ...obj, [key]: [...curGroup, mark] }
const byPage = (
obj: { [filename: string]: { [page: number]: Mark[] } },
mark: 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 {
@ -259,5 +276,5 @@ export {
convertToPdfFile,
addMarks,
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 { Event } from 'nostr-tools'
import { getRelayInfo, unixNow } from '.'
import { NostrController, relayController } from '../controllers'
import { localCache } from '../services'
import { ONE_WEEK_IN_MS, SIGIT_RELAY } from './const.ts'
import { RelayMap, RelaySet } from '../types'
import { setMostPopularRelaysAction } from '../store/actions'
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 WRITE_MARKER = 'write'
@ -24,8 +28,8 @@ const findRelayListAndUpdateCache = async (
kinds: [RelayList],
authors: [hexKey]
}
const pool = new SimplePool()
const event = await pool.get(lookUpRelays, eventFilter)
const event = await relayController.fetchEvent(eventFilter, lookUpRelays)
if (event) {
await localCache.addUserRelayListMetadata(event)
}
@ -106,11 +110,176 @@ const toRelaySet = (obj: RelaySet, tag: string[]): RelaySet => {
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 {
findRelayListAndUpdateCache,
findRelayListInCache,
getUserRelaySet,
getDefaultRelaySet,
getDefaultRelayMap,
getDefaultRelaySet,
getMostPopularRelays,
getRelayMap,
publishRelayMap,
getUserRelaySet,
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 { CurrentUserFile } from '../types/file.ts'
export const compareObjects = (
obj1: object | null | undefined,
@ -71,12 +72,20 @@ export const timeout = (ms: number = 60000) => {
* including its name, hash, and content
* @param files
* @param fileHashes
* @param creatorFileHashes
*/
export const getFilesWithHashes = (
export const getCurrentUserFiles = (
files: { [filename: string]: PdfFile },
fileHashes: { [key: string]: string | null }
) => {
return Object.entries(files).map(([filename, pdfFile]) => {
return { pdfFile, filename, hash: fileHashes[filename] }
fileHashes: { [key: string]: string | null },
creatorFileHashes: { [key: string]: string }
): CurrentUserFile[] => {
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> => {
try {
return await JSZip.loadAsync(data)
} catch (err: any) {
} catch (err) {
console.log('err in loading zip file :>> ', err)
if (err instanceof Error) {
toast.error(err.message || 'An error occurred in loading zip file.')
}
return null
}
}