Compare commits

..

3 Commits

Author SHA1 Message Date
b04f4fb88d refactor: show sent dm count, don't sent twice to creator
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
2024-10-14 13:19:44 +02:00
3b4bf9aa29 fix: only send to next signer on create
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 32s
2024-10-14 11:58:52 +02:00
e85e9519d2 feat: add private dm sending 2024-10-14 10:21:46 +02:00
40 changed files with 424 additions and 2622 deletions

View File

@ -6,7 +6,7 @@ module.exports = {
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended'
],
ignorePatterns: ['dist', '.eslintrc.cjs', 'licenseChecker.cjs', "*.min.js"],
ignorePatterns: ['dist', '.eslintrc.cjs', 'licenseChecker.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {

View File

@ -8,7 +8,6 @@
</head>
<body>
<div id="root"></div>
<script src="/opentimestamps.min.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1637
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -57,7 +57,6 @@
"react-singleton-hook": "^4.0.1",
"react-toastify": "10.0.4",
"redux": "5.0.1",
"svgo": "^3.3.2",
"tseep": "1.2.1"
},
"devDependencies": {
@ -67,7 +66,6 @@
"@types/pdfjs-dist": "^2.10.378",
"@types/react": "^18.2.56",
"@types/react-dom": "^18.2.19",
"@types/svgo": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.2.1",
@ -80,7 +78,6 @@
"ts-css-modules-vite-plugin": "1.0.20",
"typescript": "^5.2.2",
"vite": "^5.1.4",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-tsconfig-paths": "4.3.2"
},
"lint-staged": {

View File

@ -1,15 +1,15 @@
{
"names": {
"_": "6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90"
},
"relays": {
"6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90": [
"wss://brb.io",
"wss://nostr.v0l.io",
"wss://nostr.coinos.io",
"wss://rsslay.nostr.net",
"wss://relay.current.fyi",
"wss://nos.io"
]
}
"names": {
"_": "6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90"
},
"relays": {
"6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90": [
"wss://brb.io",
"wss://nostr.v0l.io",
"wss://nostr.coinos.io",
"wss://rsslay.nostr.net",
"wss://relay.current.fyi",
"wss://nos.io"
]
}
}

File diff suppressed because one or more lines are too long

View File

@ -9,7 +9,7 @@ import {
} from '@mui/material'
import styles from './style.module.scss'
import React, { useEffect, useState } from 'react'
import { ProfileMetadata, User, UserRole, KeyboardCode } from '../../types'
import { ProfileMetadata, User, UserRole } from '../../types'
import { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing'
import { hexToNpub, npubToHex, getProfileUsername } from '../../utils'
import { SigitFile } from '../../utils/file'
@ -27,10 +27,6 @@ const DEFAULT_START_SIZE = {
height: 40
} as const
interface HideSignersForDrawnField {
[key: number]: boolean
}
interface Props {
users: User[]
metadata: { [key: string]: ProfileMetadata }
@ -45,9 +41,6 @@ export const DrawPDFFields = (props: Props) => {
const signers = users.filter((u) => u.role === UserRole.signer)
const defaultSignerNpub = signers.length ? hexToNpub(signers[0].pubkey) : ''
const [lastSigner, setLastSigner] = useState(defaultSignerNpub)
const [hideSignersForDrawnField, setHideSignersForDrawnField] =
useState<HideSignersForDrawnField>({})
/**
* Return first pubkey that is present in the signers list
* @param pubkeys
@ -224,12 +217,6 @@ export const DrawPDFFields = (props: Props) => {
y: drawingRectangleCoords.y
}
})
// make signers dropdown visible
setHideSignersForDrawnField((prev) => ({
...prev,
[drawnFieldIndex]: false
}))
}
/**
@ -351,32 +338,6 @@ export const DrawPDFFields = (props: Props) => {
event.stopPropagation()
}
/**
* Handles Escape button-down event and hides all signers dropdowns
* @param event SyntheticEvent event
*/
const handleEscapeButtonDown = (event: React.SyntheticEvent) => {
// get native event
const { nativeEvent } = event
//check if event is a keyboard event
if (nativeEvent instanceof KeyboardEvent) {
// check if event code is Escape
if (nativeEvent.code === KeyboardCode.Escape) {
// hide all signers dropdowns
const newHideSignersForDrawnField: HideSignersForDrawnField = {}
Object.keys(hideSignersForDrawnField).forEach((key) => {
// Object.keys always returns an array of strings,
// that is why unknown type is used below
newHideSignersForDrawnField[key as unknown as number] = true
})
setHideSignersForDrawnField(newHideSignersForDrawnField)
}
}
}
/**
* Gets the pointer coordinates relative to a element in the `event` param
* @param event PointerEvent
@ -400,7 +361,6 @@ export const DrawPDFFields = (props: Props) => {
rect
}
}
/**
* Renders the pdf pages and drawing elements
*/
@ -415,8 +375,6 @@ export const DrawPDFFields = (props: Props) => {
<div
key={pageIndex}
className={`image-wrapper ${styles.pdfImageWrapper} ${selectedTool ? styles.drawing : ''}`}
tabIndex={-1}
onKeyDown={(event) => handleEscapeButtonDown(event)}
>
<img
onPointerMove={(event) => {
@ -534,78 +492,62 @@ export const DrawPDFFields = (props: Props) => {
fileIndex,
pageIndex,
drawnFieldIndex
) &&
(!hideSignersForDrawnField ||
!hideSignersForDrawnField[drawnFieldIndex]) && (
<div
onPointerDown={handleUserSelectPointerDown}
className={styles.userSelect}
>
<FormControl fullWidth size="small">
<InputLabel id="counterparts">
Counterpart
</InputLabel>
<Select
value={getAvailableSigner(drawnField.counterpart)}
onChange={(event) => {
drawnField.counterpart = event.target.value
setLastSigner(event.target.value)
refreshPdfFiles()
}}
labelId="counterparts"
label="Counterparts"
sx={{
background: 'white'
}}
renderValue={(value) =>
renderCounterpartValue(value)
}
>
{signers.map((signer, index) => {
const npub = hexToNpub(signer.pubkey)
const metadata = props.metadata[signer.pubkey]
const displayValue = getProfileUsername(
npub,
metadata
)
// make current signers dropdown visible
if (
hideSignersForDrawnField[drawnFieldIndex] ===
undefined ||
hideSignersForDrawnField[drawnFieldIndex] ===
true
) {
setHideSignersForDrawnField((prev) => ({
...prev,
[drawnFieldIndex]: false
}))
}
) && (
<div
onPointerDown={handleUserSelectPointerDown}
className={styles.userSelect}
>
<FormControl fullWidth size="small">
<InputLabel id="counterparts">Counterpart</InputLabel>
<Select
value={getAvailableSigner(drawnField.counterpart)}
onChange={(event) => {
drawnField.counterpart = event.target.value
setLastSigner(event.target.value)
refreshPdfFiles()
}}
labelId="counterparts"
label="Counterparts"
sx={{
background: 'white'
}}
renderValue={(value) =>
renderCounterpartValue(value)
}
>
{signers.map((signer, index) => {
const npub = hexToNpub(signer.pubkey)
const metadata = props.metadata[signer.pubkey]
const displayValue = getProfileUsername(
npub,
metadata
)
return (
<MenuItem key={index} value={npub}>
<ListItemIcon>
<AvatarIconButton
src={metadata?.picture}
hexKey={signer.pubkey}
aria-label={`account of user ${displayValue}`}
color="inherit"
sx={{
padding: 0,
'> img': {
width: '30px',
height: '30px'
}
}}
/>
</ListItemIcon>
<ListItemText>{displayValue}</ListItemText>
</MenuItem>
)
})}
</Select>
</FormControl>
</div>
)}
return (
<MenuItem key={index} value={npub}>
<ListItemIcon>
<AvatarIconButton
src={metadata?.picture}
hexKey={signer.pubkey}
aria-label={`account of user ${displayValue}`}
color="inherit"
sx={{
padding: 0,
'> img': {
width: '30px',
height: '30px'
}
}}
/>
</ListItemIcon>
<ListItemText>{displayValue}</ListItemText>
</MenuItem>
)
})}
</Select>
</FormControl>
</div>
)}
</div>
)
})}

View File

@ -13,10 +13,6 @@
}
}
.pdfImageWrapper:focus {
outline: none;
}
.placeholder {
position: absolute;
opacity: 0.5;

View File

@ -7,12 +7,13 @@ import {
isCurrentValueLast
} from '../../utils'
import React, { useState } from 'react'
import { MARK_TYPE_CONFIG } from '../getMarkComponents.tsx'
interface MarkFormFieldProps {
currentUserMarks: CurrentUserMark[]
handleCurrentUserMarkChange: (mark: CurrentUserMark) => void
handleSelectedMarkValueChange: (value: string) => void
handleSelectedMarkValueChange: (
event: React.ChangeEvent<HTMLInputElement>
) => void
handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void
selectedMark: CurrentUserMark
selectedMarkValue: string
@ -52,8 +53,6 @@ const MarkFormField = ({
}
const toggleActions = () => setDisplayActions(!displayActions)
const markLabel = getToolboxLabelByMarkType(selectedMark.mark.type)
const { input: MarkInputComponent } =
MARK_TYPE_CONFIG[selectedMark.mark.type] || {}
return (
<div className={styles.container}>
<div className={styles.trigger}>
@ -84,14 +83,12 @@ const MarkFormField = ({
</div>
<div className={styles.inputWrapper}>
<form onSubmit={(e) => handleFormSubmit(e)}>
{typeof MarkInputComponent !== 'undefined' && (
<MarkInputComponent
value={selectedMarkValue}
placeholder={markLabel}
handler={handleSelectedMarkValueChange}
userMark={selectedMark}
/>
)}
<input
className={styles.input}
placeholder={markLabel}
onChange={handleSelectedMarkValueChange}
value={selectedMarkValue}
/>
<div className={styles.actionsBottom}>
<button type="submit" className={styles.submitButton}>
NEXT

View File

@ -1,44 +0,0 @@
@import '../../styles/colors.scss';
$padding: 5px;
.wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: $padding;
}
.relative {
position: relative;
}
.canvas {
outline: 1px solid black;
background-color: $body-background-color;
cursor: crosshair;
// Disable panning/zooming when touching canvas element
-ms-touch-action: none;
touch-action: none;
-webkit-user-select: none;
user-select: none;
}
.absolute {
position: absolute;
inset: 0;
pointer-events: none;
}
.reset {
cursor: pointer;
position: absolute;
top: 0;
right: $padding;
color: $primary-main;
&:hover {
color: $primary-dark;
}
}

View File

@ -1,101 +0,0 @@
import { useRef, useState } from 'react'
import { MarkInputProps } from '../../types/mark'
import { getOptimizedPaths, optimizeSVG } from '../../utils'
import { faEraser } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import styles from './Signature.module.scss'
import { MarkRenderSignature } from '../MarkRender/Signature'
export const MarkInputSignature = ({
value,
handler,
userMark
}: MarkInputProps) => {
const location = userMark?.mark.location
const canvasRef = useRef<HTMLCanvasElement>(null)
const [drawing, setDrawing] = useState(false)
const [paths, setPaths] = useState<string[]>(value ? JSON.parse(value) : [])
function update() {
if (location && paths) {
if (paths.length) {
const optimizedSvg = optimizeSVG(location, paths)
const extractedPaths = getOptimizedPaths(optimizedSvg)
handler(JSON.stringify(extractedPaths))
} else {
handler('')
}
}
}
const handlePointerDown = (event: React.PointerEvent) => {
const rect = event.currentTarget.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
const ctx = canvasRef.current?.getContext('2d')
ctx?.beginPath()
ctx?.moveTo(x, y)
setPaths([...paths, `M ${x} ${y}`])
setDrawing(true)
}
const handlePointerUp = () => {
setDrawing(false)
update()
const ctx = canvasRef.current?.getContext('2d')
ctx?.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height)
}
const handlePointerMove = (event: React.PointerEvent) => {
if (!drawing) return
const ctx = canvasRef.current?.getContext('2d')
const rect = canvasRef.current?.getBoundingClientRect()
const x = event.clientX - rect!.left
const y = event.clientY - rect!.top
ctx?.lineTo(x, y)
ctx?.stroke()
// Collect the path data
setPaths((prevPaths) => {
const newPaths = [...prevPaths]
newPaths[newPaths.length - 1] += ` L ${x} ${y}`
return newPaths
})
}
const handleReset = () => {
setPaths([])
setDrawing(false)
update()
const ctx = canvasRef.current?.getContext('2d')
ctx?.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height)
}
return (
<>
<div className={styles.wrapper}>
<div className={styles.relative}>
<canvas
height={location?.height}
width={location?.width}
ref={canvasRef}
className={styles.canvas}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerMove={handlePointerMove}
onPointerOut={handlePointerUp}
></canvas>
{typeof userMark?.mark !== 'undefined' && (
<div className={styles.absolute}>
<MarkRenderSignature value={value} mark={userMark.mark} />
</div>
)}
<div className={styles.reset}>
<FontAwesomeIcon size="sm" icon={faEraser} onClick={handleReset} />
</div>
</div>
</div>
</>
)
}

View File

@ -1,19 +0,0 @@
import { MarkInputProps } from '../../types/mark'
import styles from '../MarkFormField/style.module.scss'
export const MarkInputText = ({
value,
handler,
placeholder
}: MarkInputProps) => {
return (
<input
className={styles.input}
placeholder={placeholder}
onChange={(e) => {
handler(e.currentTarget.value)
}}
value={value}
/>
)
}

View File

@ -1,13 +0,0 @@
import { MarkRenderProps } from '../../types/mark'
export const MarkRenderSignature = ({ value, mark }: MarkRenderProps) => {
const paths = value ? JSON.parse(value) : []
return (
<svg viewBox={`0 0 ${mark.location.width} ${mark.location.height}`}>
{paths.map((path: string) => (
<path d={path} stroke="black" fill="none" />
))}
</svg>
)
}

View File

@ -4,7 +4,6 @@ import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
import { useScale } from '../../hooks/useScale.tsx'
import { forwardRef } from 'react'
import { npubToHex } from '../../utils/nostr.ts'
import { MARK_TYPE_CONFIG } from '../getMarkComponents.tsx'
interface PdfMarkItemProps {
userMark: CurrentUserMark
@ -28,8 +27,6 @@ const PdfMarkItem = forwardRef<HTMLDivElement, PdfMarkItemProps>(
const getMarkValue = () =>
isEdited() ? selectedMarkValue : userMark.currentValue
const { from } = useScale()
const { render: MarkRenderComponent } =
MARK_TYPE_CONFIG[userMark.mark.type] || {}
return (
<div
ref={ref}
@ -50,9 +47,7 @@ const PdfMarkItem = forwardRef<HTMLDivElement, PdfMarkItemProps>(
fontSize: inPx(from(pageWidth, FONT_SIZE))
}}
>
{typeof MarkRenderComponent !== 'undefined' && (
<MarkRenderComponent value={getMarkValue()} mark={userMark.mark} />
)}
{getMarkValue()}
</div>
)
}

View File

@ -117,7 +117,8 @@ const PdfMarking = (props: PdfMarkingProps) => {
// setCurrentUserMarks(updatedCurrentUserMarks)
// }
const handleChange = (value: string) => setSelectedMarkValue(value)
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) =>
setSelectedMarkValue(event.target.value)
return (
<>

View File

@ -4,7 +4,6 @@ import {
fromUnixTimestamp,
hexToNpub,
npubToHex,
SigitStatus,
SignStatus
} from '../../utils'
import { useSigitMeta } from '../../hooks/useSigitMeta'
@ -16,8 +15,6 @@ import {
faCalendar,
faCalendarCheck,
faCalendarPlus,
faCheck,
faClock,
faEye,
faFile,
faFileCircleExclamation
@ -25,7 +22,7 @@ import {
import { getExtensionIconLabel } from '../getExtensionIconLabel'
import { useAppSelector } from '../../hooks/store'
import { DisplaySigner } from '../DisplaySigner'
import { Meta, OpenTimestamp } from '../../types'
import { Meta } from '../../types'
import { extractFileExtensions } from '../../utils/file'
import { UserAvatar } from '../UserAvatar'
@ -45,9 +42,7 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
completedAt,
parsedSignatureEvents,
signedStatus,
isValid,
id,
timestamps
isValid
} = useSigitMeta(meta)
const { usersPubkey } = useAppSelector((state) => state.auth)
const userCanSign =
@ -56,50 +51,6 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes))
const isTimestampVerified = (
timestamps: OpenTimestamp[],
nostrId: string
): boolean => {
const matched = timestamps.find((t) => t.nostrId === nostrId)
return !!(matched && matched.verification)
}
const getOpenTimestampsInfo = (
timestamps: OpenTimestamp[],
nostrId: string
) => {
if (isTimestampVerified(timestamps, nostrId)) {
return <FontAwesomeIcon className={styles.ticket} icon={faCheck} />
} else {
return <FontAwesomeIcon className={styles.ticket} icon={faClock} />
}
}
const getCompletedOpenTimestampsInfo = (timestamp: OpenTimestamp) => {
if (timestamp.verification) {
return <FontAwesomeIcon className={styles.ticket} icon={faCheck} />
} else {
return <FontAwesomeIcon className={styles.ticket} icon={faClock} />
}
}
const getTimestampTooltipTitle = (label: string, isVerified: boolean) => {
return `${label} / Open Timestamp ${isVerified ? 'Verified' : 'Pending'}`
}
const isUserSignatureTimestampVerified = () => {
if (
userCanSign &&
hexToNpub(usersPubkey) in parsedSignatureEvents &&
timestamps &&
timestamps.length > 0
) {
const nostrId = parsedSignatureEvents[hexToNpub(usersPubkey)].id
return isTimestampVerified(timestamps, nostrId)
}
return false
}
return submittedBy ? (
<div className={styles.container}>
<div className={styles.section}>
@ -164,35 +115,19 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
<p>Details</p>
<Tooltip
title={getTimestampTooltipTitle(
'Publication date',
!!(timestamps && id && isTimestampVerified(timestamps, id))
)}
title={'Publication date'}
placement="top"
arrow
disableInteractive
>
<span className={styles.detailsItem}>
<FontAwesomeIcon icon={faCalendarPlus} />{' '}
{createdAt ? formatTimestamp(createdAt) : <>&mdash;</>}{' '}
{timestamps &&
timestamps.length > 0 &&
id &&
getOpenTimestampsInfo(timestamps, id)}
{createdAt ? formatTimestamp(createdAt) : <>&mdash;</>}
</span>
</Tooltip>
<Tooltip
title={getTimestampTooltipTitle(
'Completion date',
!!(
signedStatus === SigitStatus.Complete &&
completedAt &&
timestamps &&
timestamps.length > 0 &&
timestamps[timestamps.length - 1].verification
)
)}
title={'Completion date'}
placement="top"
arrow
disableInteractive
@ -200,26 +135,13 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
<span className={styles.detailsItem}>
<FontAwesomeIcon icon={faCalendarCheck} />{' '}
{completedAt ? formatTimestamp(completedAt) : <>&mdash;</>}
{signedStatus === SigitStatus.Complete &&
completedAt &&
timestamps &&
timestamps.length > 0 && (
<span className={styles.ticket}>
{getCompletedOpenTimestampsInfo(
timestamps[timestamps.length - 1]
)}
</span>
)}
</span>
</Tooltip>
{/* User signed date */}
{userCanSign ? (
<Tooltip
title={getTimestampTooltipTitle(
'Your signature date',
isUserSignatureTimestampVerified()
)}
title={'Your signature date'}
placement="top"
arrow
disableInteractive
@ -239,16 +161,6 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
) : (
<>&mdash;</>
)}
{hexToNpub(usersPubkey) in parsedSignatureEvents &&
timestamps &&
timestamps.length > 0 && (
<span className={styles.ticket}>
{getOpenTimestampsInfo(
timestamps,
parsedSignatureEvents[hexToNpub(usersPubkey)].id
)}
</span>
)}
</span>
</Tooltip>
) : null}

View File

@ -31,6 +31,8 @@
padding: 5px;
display: flex;
align-items: center;
justify-content: start;
> :first-child {
padding: 5px;
@ -42,7 +44,3 @@
color: white;
}
}
.ticket {
margin-left: auto;
}

View File

@ -1,16 +0,0 @@
import { MarkType } from '../types/drawing'
import { MarkConfigs } from '../types/mark'
import { MarkInputSignature } from './MarkInputs/Signature'
import { MarkInputText } from './MarkInputs/Text'
import { MarkRenderSignature } from './MarkRender/Signature'
export const MARK_TYPE_CONFIG: MarkConfigs = {
[MarkType.TEXT]: {
input: MarkInputText,
render: ({ value }) => <>{value}</>
},
[MarkType.SIGNATURE]: {
input: MarkInputSignature,
render: MarkRenderSignature
}
}

View File

@ -3,8 +3,7 @@ import {
CreateSignatureEventContent,
DocSignatureEvent,
Meta,
SignedEventContent,
OpenTimestamp
SignedEventContent
} from '../types'
import { Mark } from '../types/mark'
import {
@ -59,8 +58,6 @@ export interface FlatMeta
signersStatus: {
[signer: `npub1${string}`]: SignStatus
}
timestamps?: OpenTimestamp[]
}
/**
@ -165,6 +162,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
setEncryptionKey(decrypted)
}
}
// Temp. map to hold events and signers
const parsedSignatureEventsMap = new Map<
`npub1${string}`,
@ -278,7 +276,6 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
createSignature: meta?.createSignature,
docSignatures: meta?.docSignatures,
keys: meta?.keys,
timestamps: meta?.timestamps,
isValid,
kind,
tags,

View File

@ -19,10 +19,8 @@ import {
CreateSignatureEventContent,
Meta,
ProfileMetadata,
SignedEvent,
User,
UserRole,
KeyboardCode
UserRole
} from '../../types'
import {
encryptArrayBuffer,
@ -41,7 +39,9 @@ import {
updateUsersAppData,
uploadToFileStorage,
DEFAULT_TOOLBOX,
settleAllFullfilfedPromises
settleAllFullfilfedPromises,
sendPrivateDirectMessage,
parseNostrEvent
} from '../../utils'
import { Container } from '../../components/Container'
import fileListStyles from '../../components/FileList/style.module.scss'
@ -64,7 +64,7 @@ import {
} from '@fortawesome/free-solid-svg-icons'
import { getSigitFile, SigitFile } from '../../utils/file.ts'
import _ from 'lodash'
import { generateTimestamp } from '../../utils/opentimestamps.ts'
import { SendDMError } from '../../types/errors/SendDMError.ts'
export const CreatePage = () => {
const navigate = useNavigate()
@ -88,10 +88,7 @@ export const CreatePage = () => {
const [userInput, setUserInput] = useState('')
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (
event.code === KeyboardCode.Enter ||
event.code === KeyboardCode.NumpadEnter
) {
if (event.code === 'Enter' || event.code === 'NumpadEnter') {
event.preventDefault()
handleAddUser()
}
@ -648,11 +645,6 @@ export const CreatePage = () => {
return receivers.map((receiver) => sendNotification(receiver, meta))
}
const extractNostrId = (stringifiedEvent: string): string => {
const e = JSON.parse(stringifiedEvent) as SignedEvent
return e.id
}
const handleCreate = async () => {
try {
if (!validateInputs()) return
@ -702,12 +694,6 @@ export const CreatePage = () => {
const keys = await generateKeys(pubkeys, encryptionKey)
if (!keys) return
setLoadingSpinnerDesc('Generating an open timestamp.')
const timestamp = await generateTimestamp(
extractNostrId(createSignature)
)
const meta: Meta = {
createSignature,
keys,
@ -715,10 +701,6 @@ export const CreatePage = () => {
docSignatures: {}
}
if (timestamp) {
meta.timestamps = [timestamp]
}
setLoadingSpinnerDesc('Updating user app data')
const event = await updateUsersAppData(meta)
if (!event) return
@ -734,6 +716,31 @@ export const CreatePage = () => {
toast.error('Failed to publish notifications')
})
// Send DM to the next signer
setLoadingSpinnerDesc('Sending DMs')
if (signers.length > 0 && signers[0].pubkey !== usersPubkey) {
// No need to send notification to self so remove it from the list
const nextSigner = signers[0].pubkey
if (nextSigner) {
const createSignatureEvent = await parseNostrEvent(
meta.createSignature
)
const { id } = createSignatureEvent
try {
await sendPrivateDirectMessage(
`Sigit created, visit ${window.location.origin}/#/sign/${id}`,
npubToHex(nextSigner)!
)
} catch (error) {
if (error instanceof SendDMError) {
toast.error(error.message)
}
console.error(error)
}
}
}
navigate(appPrivateRoutes.sign, { state: { meta } })
} else {
const zip = new JSZip()

View File

@ -69,8 +69,8 @@ export const LandingPage = () => {
title: <>Verifiable</>,
description: (
<>
SIGit Agreements can be directly verified - unlike traditional,
server-based offerings.
Thanks to Schnorr Signatures and Web of Trust, SIGit is far more
auditable than traditional server-based offerings.
</>
)
},
@ -84,8 +84,8 @@ export const LandingPage = () => {
title: <>Works Offline</>,
description: (
<>
It is possible to complete a SIGit round without an internet
connection.
Presuming you have a hardware signing device, it is possible to
complete a SIGit round without an internet connection.
</>
)
},
@ -94,8 +94,8 @@ export const LandingPage = () => {
title: <>Multi-Party Signing</>,
description: (
<>
Choose any number of Signers and Viewers, track status, get
notifications on completion.
Choose any number of Signers and Viewers, track the signature status,
send reminders, get notifications on completion.
</>
)
}

View File

@ -9,7 +9,6 @@ import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { AuthController } from '../../controllers'
import { updateKeyPair, updateLoginMethod } from '../../store/actions'
import { KeyboardCode } from '../../types'
import { LoginMethod } from '../../store/auth/types'
import { hexToBytes } from '@noble/hashes/utils'
@ -53,10 +52,7 @@ export const Nostr = () => {
* Call login function when enter is pressed
*/
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (
event.code === KeyboardCode.Enter ||
event.code === KeyboardCode.NumpadEnter
) {
if (event.code === 'Enter' || event.code === 'NumpadEnter') {
event.preventDefault()
login()
}

View File

@ -33,7 +33,9 @@ import {
signEventForMetaFile,
updateUsersAppData,
findOtherUserMarks,
timeout
timeout,
sendPrivateDirectMessage,
parseNostrEvent
} from '../../utils'
import { Container } from '../../components/Container'
import { DisplayMeta } from './internal/displayMeta'
@ -53,7 +55,7 @@ import {
SigitFile
} from '../../utils/file.ts'
import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts'
import { generateTimestamp } from '../../utils/opentimestamps.ts'
import { SendDMError } from '../../types/errors/SendDMError.ts'
enum SignedStatus {
Fully_Signed,
@ -537,11 +539,7 @@ export const SignPage = () => {
setIsLoading(true)
const arrayBuffer = await decrypt(selectedFile)
if (!arrayBuffer) {
setIsLoading(false)
toast.error('Error decrypting file')
return
}
if (!arrayBuffer) return
handleDecryptedArrayBuffer(arrayBuffer)
}
@ -567,14 +565,6 @@ export const SignPage = () => {
const updatedMeta = updateMetaSignatures(meta, signedEvent)
setLoadingSpinnerDesc('Generating an open timestamp.')
const timestamp = await generateTimestamp(signedEvent.id)
if (timestamp) {
updatedMeta.timestamps = [...(updatedMeta.timestamps || []), timestamp]
updatedMeta.modifiedAt = unixNow()
}
if (await isOnline()) {
await handleOnlineFlow(updatedMeta)
} else {
@ -733,6 +723,65 @@ export const SignPage = () => {
toast.error('Failed to publish notifications')
})
// Send DMs
setLoadingSpinnerDesc('Sending DMs')
const createSignatureEvent = await parseNostrEvent(meta.createSignature)
const { id } = createSignatureEvent
if (isLastSigner) {
// Final sign sends to everyone (creator, signers, viewers - /verify)
const areSent: boolean[] = Array(users.length).fill(false)
for (let i = 0; i < users.length; i++) {
try {
areSent[i] = await sendPrivateDirectMessage(
`Sigit completed, visit ${window.location.origin}/#/verify/${id}`,
npubToHex(users[i])!
)
} catch (error) {
if (error instanceof SendDMError) {
toast.error(error.message)
}
console.error(error)
}
}
if (areSent.some((r) => r)) {
toast.success(
`DMs sent ${areSent.filter((r) => r).length}/${users.length}`
)
}
} else {
// Notify the creator and the next signer (/sign).
try {
await sendPrivateDirectMessage(
`Sigit signed by ${usersNpub}, visit ${window.location.origin}/#/sign/${id}`,
npubToHex(submittedBy!)!
)
} catch (error) {
if (error instanceof SendDMError) {
toast.error(error.message)
}
console.error(error)
}
// No need to notify creator twice, skipping
const currentSignerIndex = signers.indexOf(usersNpub)
const nextSigner = signers[currentSignerIndex + 1]
if (nextSigner !== submittedBy) {
try {
await sendPrivateDirectMessage(
`You're the next signer, visit ${window.location.origin}/#/sign/${id}`,
npubToHex(nextSigner)!
)
} catch (error) {
if (error instanceof SendDMError) {
toast.error(error.message)
}
console.error(error)
}
}
}
setIsLoading(false)
}
@ -784,9 +833,14 @@ export const SignPage = () => {
2
)
const zip = await getZipWithFiles(meta, files)
const zip = new JSZip()
zip.file('meta.json', stringifiedMeta)
for (const [fileName, file] of Object.entries(files)) {
zip.file(`files/${fileName}`, await file.arrayBuffer())
}
const arrayBuffer = await zip
.generateAsync({
type: 'arraybuffer',
@ -815,11 +869,16 @@ export const SignPage = () => {
const handleEncryptedExport = async () => {
if (Object.entries(files).length === 0 || !meta) return
const zip = new JSZip()
const stringifiedMeta = JSON.stringify(meta, null, 2)
const zip = await getZipWithFiles(meta, files)
zip.file('meta.json', stringifiedMeta)
for (const [fileName, file] of Object.entries(files)) {
zip.file(`files/${fileName}`, await file.arrayBuffer())
}
const arrayBuffer = await zip
.generateAsync({
type: 'arraybuffer',

View File

@ -5,13 +5,7 @@ import { useEffect, useRef, useState } from 'react'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { NostrController } from '../../controllers'
import {
DocSignatureEvent,
Meta,
SignedEvent,
OpenTimestamp,
OpenTimestampUpgradeVerifyResponse
} from '../../types'
import { DocSignatureEvent, Meta } from '../../types'
import {
decryptArrayBuffer,
getHash,
@ -20,10 +14,7 @@ import {
parseJson,
readContentOfZipEntry,
signEventForMetaFile,
getCurrentUserFiles,
updateUsersAppData,
npubToHex,
sendNotification
getCurrentUserFiles
} from '../../utils'
import styles from './style.module.scss'
import { useLocation } from 'react-router-dom'
@ -35,7 +26,7 @@ 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'
import { UsersDetails } from '../../components/UsersDetails.tsx/index.tsx'
import FileList from '../../components/FileList'
import { CurrentUserFile } from '../../types/file.ts'
import { Mark } from '../../types/mark.ts'
@ -53,9 +44,6 @@ import {
faFile,
faFileDownload
} from '@fortawesome/free-solid-svg-icons'
import { upgradeAndVerifyTimestamp } from '../../utils/opentimestamps.ts'
import _ from 'lodash'
import { MARK_TYPE_CONFIG } from '../../components/getMarkComponents.tsx'
interface PdfViewProps {
files: CurrentUserFile[]
@ -115,8 +103,6 @@ const SlimPdfView = ({
alt={`page ${i} of ${file.name}`}
/>
{marks.map((m) => {
const { render: MarkRenderComponent } =
MARK_TYPE_CONFIG[m.type] || {}
return (
<div
className={`file-mark ${styles.mark}`}
@ -132,9 +118,7 @@ const SlimPdfView = ({
fontSize: inPx(from(page.width, FONT_SIZE))
}}
>
{typeof MarkRenderComponent !== 'undefined' && (
<MarkRenderComponent value={m.value} mark={m} />
)}
{m.value}
</div>
)
})}
@ -192,8 +176,7 @@ export const VerifyPage = () => {
signers,
viewers,
fileHashes,
parsedSignatureEvents,
timestamps
parsedSignatureEvents
} = useSigitMeta(meta)
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
@ -203,16 +186,6 @@ export const VerifyPage = () => {
[key: string]: string | null
}>({})
const signTimestampEvent = async (signerContent: {
timestamps: OpenTimestamp[]
}): Promise<SignedEvent | null> => {
return await signEventForMetaFile(
JSON.stringify(signerContent),
nostrController,
setIsLoading
)
}
useEffect(() => {
if (Object.entries(files).length > 0) {
const tmp = getCurrentUserFiles(files, currentFileHashes, fileHashes)
@ -220,147 +193,6 @@ export const VerifyPage = () => {
}
}, [currentFileHashes, fileHashes, files])
useEffect(() => {
if (
timestamps &&
timestamps.length > 0 &&
usersPubkey &&
submittedBy &&
parsedSignatureEvents
) {
if (timestamps.every((t) => !!t.verification)) {
return
}
const upgradeT = async (timestamps: OpenTimestamp[]) => {
try {
setLoadingSpinnerDesc('Upgrading your timestamps.')
const findCreatorTimestamp = (timestamps: OpenTimestamp[]) => {
if (usersPubkey === submittedBy) {
return timestamps[0]
}
}
const findSignerTimestamp = (timestamps: OpenTimestamp[]) => {
const parsedEvent = parsedSignatureEvents[hexToNpub(usersPubkey)]
if (parsedEvent?.id) {
return timestamps.find((t) => t.nostrId === parsedEvent.id)
}
}
/**
* Checks if timestamp verification has been achieved for the first time.
* Note that the upgrade flag is separate from verification. It is possible for a timestamp
* to not be upgraded, but to be verified for the first time.
* @param upgradedTimestamp
* @param timestamps
*/
const isNewlyVerified = (
upgradedTimestamp: OpenTimestampUpgradeVerifyResponse,
timestamps: OpenTimestamp[]
) => {
if (!upgradedTimestamp.verified) return false
const oldT = timestamps.find(
(t) => t.nostrId === upgradedTimestamp.timestamp.nostrId
)
if (!oldT) return false
if (!oldT.verification && upgradedTimestamp.verified) return true
}
const userTimestamps: OpenTimestamp[] = []
const creatorTimestamp = findCreatorTimestamp(timestamps)
if (creatorTimestamp) {
userTimestamps.push(creatorTimestamp)
}
const signerTimestamp = findSignerTimestamp(timestamps)
if (signerTimestamp) {
userTimestamps.push(signerTimestamp)
}
if (userTimestamps.every((t) => !!t.verification)) {
return
}
const upgradedUserTimestamps = await Promise.all(
userTimestamps.map(upgradeAndVerifyTimestamp)
)
const upgradedTimestamps = upgradedUserTimestamps
.filter((t) => t.upgraded || isNewlyVerified(t, userTimestamps))
.map((t) => {
const timestamp: OpenTimestamp = { ...t.timestamp }
if (t.verified) {
timestamp.verification = t.verification
}
return timestamp
})
if (upgradedTimestamps.length === 0) {
return
}
setLoadingSpinnerDesc('Signing a timestamp upgrade event.')
const signedEvent = await signTimestampEvent({
timestamps: upgradedTimestamps
})
if (!signedEvent) return
const finalTimestamps = timestamps.map((t) => {
const upgraded = upgradedTimestamps.find(
(tu) => tu.nostrId === t.nostrId
)
if (upgraded) {
return {
...upgraded,
signature: JSON.stringify(signedEvent, null, 2)
}
}
return t
})
const updatedMeta = _.cloneDeep(meta)
updatedMeta.timestamps = [...finalTimestamps]
updatedMeta.modifiedAt = unixNow()
const updatedEvent = await updateUsersAppData(updatedMeta)
if (!updatedEvent) return
const userSet = new Set<`npub1${string}`>()
signers.forEach((signer) => {
if (signer !== usersPubkey) {
userSet.add(signer)
}
})
viewers.forEach((viewer) => {
userSet.add(viewer)
})
const users = Array.from(userSet)
const promises = users.map((user) =>
sendNotification(npubToHex(user)!, updatedMeta)
)
await Promise.all(promises)
toast.success('Timestamp updates have been sent successfully.')
setMeta(meta)
} catch (err) {
console.error(err)
toast.error(
'There was an error upgrading or verifying your timestamps!'
)
}
}
upgradeT(timestamps)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timestamps, submittedBy, parsedSignatureEvents])
useEffect(() => {
if (metaInNavState && encryptionKey) {
const processSigit = async () => {

View File

@ -18,7 +18,6 @@ export interface Meta {
docSignatures: { [key: `npub1${string}`]: string }
exportSignature?: string
keys?: { sender: string; keys: { [user: `npub1${string}`]: string } }
timestamps?: OpenTimestamp[]
}
export interface CreateSignatureEventContent {
@ -40,25 +39,6 @@ export interface Sigit {
meta: Meta
}
export interface OpenTimestamp {
nostrId: string
value: string
verification?: OpenTimestampVerification
signature?: string
}
export interface OpenTimestampVerification {
height: number
timestamp: number
}
export interface OpenTimestampUpgradeVerifyResponse {
timestamp: OpenTimestamp
upgraded: boolean
verified?: boolean
verification?: OpenTimestampVerification
}
export interface UserAppData {
/**
* Key will be id of create signature

View File

@ -0,0 +1,24 @@
import { Jsonable } from '.'
export enum SendDMErrorType {
'METADATA_FETCH_FAILED' = 'Sending DM failed. An error occured while fetching user metadata.',
'RELAY_READ_EMPTY' = `Sending DM failed. The user's relay read set is empty.`,
'ENCRYPTION_FAILED' = 'Sending DM failed. An error occurred in encrypting dm message.',
'RELAY_PUBLISH_FAILED' = 'Sending DM failed. Publishing events failed.'
}
export class SendDMError extends Error {
public readonly context?: Jsonable
constructor(
message: string,
options: { cause?: Error; context?: Jsonable } = {}
) {
const { cause, context } = options
super(message, { cause })
this.name = this.constructor.name
this.context = context
}
}

View File

@ -1,5 +0,0 @@
export enum KeyboardCode {
Escape = 'Escape',
Enter = 'Enter',
NumpadEnter = 'NumpadEnter'
}

View File

@ -4,4 +4,3 @@ export * from './nostr'
export * from './profile'
export * from './relay'
export * from './zip'
export * from './event'

View File

@ -28,24 +28,3 @@ export interface MarkRect {
width: number
height: number
}
export interface MarkInputProps {
value: string
handler: (value: string) => void
placeholder?: string
userMark?: CurrentUserMark
}
export interface MarkRenderProps {
value?: string
mark: Mark
}
export interface MarkConfig {
input: React.FC<MarkInputProps>
render?: React.FC<MarkRenderProps>
}
export type MarkConfigs = {
[key in MarkType]?: MarkConfig
}

View File

@ -1,38 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
interface OpenTimestamps {
// Create a detached timestamp file from a buffer or file hash
DetachedTimestampFile: {
fromHash(op: any, hash: Uint8Array): any
fromBytes(op: any, buffer: Uint8Array): any
deserialize(buffer: any): any
}
// Stamp the provided timestamp file and return a Promise
stamp(file: any): Promise<void>
// Verify the provided timestamp proof file
verify(
ots: string,
file: string
): Promise<TimestampVerficiationResponse | Record<string, never>>
// Other utilities or operations (like OpSHA256, serialization)
Ops: {
OpSHA256: any
OpSHA1?: any
}
Context: {
StreamSerialization: any
}
// Load a timestamp file from a buffer
deserialize(bytes: Uint8Array): any
// Other potential methods based on repo functions
upgrade(file: any): Promise<boolean>
}
interface TimestampVerficiationResponse {
bitcoin: { timestamp: number; height: number }
}

View File

@ -3,6 +3,5 @@ import type { WindowNostr } from 'nostr-tools/nip07'
declare global {
interface Window {
nostr?: WindowNostr
OpenTimestamps: OpenTimestamps
}
}

View File

@ -20,7 +20,7 @@ export const getZipWithFiles = async (
for (const [fileName, file] of Object.entries(files)) {
// Handle PDF Files, add marks
if (file.isPdf && fileName in marksByFileNamePage) {
if (file.isPdf) {
const blob = await addMarks(file, marksByFileNamePage[fileName])
zip.file(`marked/${fileName}`, blob)
}

View File

@ -1,4 +1,4 @@
import { CurrentUserMark, Mark, MarkLocation } from '../types/mark.ts'
import { CurrentUserMark, Mark } from '../types/mark.ts'
import { hexToNpub } from './nostr.ts'
import { Meta, SignedEventContent } from '../types'
import { Event } from 'nostr-tools'
@ -24,7 +24,6 @@ import {
faStamp,
faTableCellsLarge
} from '@fortawesome/free-solid-svg-icons'
import { Config, optimize } from 'svgo'
/**
* Takes in an array of Marks already filtered by User.
@ -159,11 +158,6 @@ export const DEFAULT_TOOLBOX: DrawTool[] = [
icon: faT,
label: 'Text'
},
{
identifier: MarkType.SIGNATURE,
icon: faSignature,
label: 'Signature'
},
{
identifier: MarkType.FULLNAME,
icon: faIdCard,
@ -176,6 +170,12 @@ export const DEFAULT_TOOLBOX: DrawTool[] = [
label: 'Job Title',
isComingSoon: true
},
{
identifier: MarkType.SIGNATURE,
icon: faSignature,
label: 'Signature',
isComingSoon: true
},
{
identifier: MarkType.DATETIME,
icon: faClock,
@ -266,24 +266,6 @@ export const getToolboxLabelByMarkType = (markType: MarkType) => {
return DEFAULT_TOOLBOX.find((t) => t.identifier === markType)?.label
}
export const optimizeSVG = (location: MarkLocation, paths: string[]) => {
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="${location.width}" height="${location.height}">${paths.map((path) => `<path d="${path}" stroke="black" fill="none" />`).join('')}</svg>`
const optimizedSVG = optimize(svgContent, {
multipass: true, // Optimize multiple times if needed
floatPrecision: 2 // Adjust precision
} as Config)
return optimizedSVG.data
}
export const getOptimizedPaths = (svgString: string) => {
const regex = / d="([^"]*)"/g
const matches = [...svgString.matchAll(regex)]
const pathValues = matches.map((match) => match[1])
return pathValues
}
export {
getCurrentUserMarks,
filterMarksByPubkey,

View File

@ -6,6 +6,7 @@ import {
EventTemplate,
Filter,
UnsignedEvent,
VerifiedEvent,
finalizeEvent,
generateSecretKey,
getEventHash,
@ -21,7 +22,8 @@ import { NIP05_REGEX } from '../constants'
import {
MetadataController,
NostrController,
relayController
relayController,
RelayController
} from '../controllers'
import {
updateProcessedGiftWraps,
@ -35,6 +37,7 @@ import { parseJson, removeLeadingSlash } from './string'
import { timeout } from './utils'
import { getHash } from './hash'
import { SIGIT_BLOSSOM } from './const.ts'
import { SendDMError, SendDMErrorType } from '../types/errors/SendDMError.ts'
/**
* Generates a `d` tag for userAppData
@ -248,6 +251,12 @@ export const toUnixTimestamp = (date: number | Date) => {
export const fromUnixTimestamp = (unix: number) => {
return unix * 1000
}
export const randomTimeUpTo2DaysInThePast = (): number => {
const now = Date.now()
const twoDaysInMilliseconds = 2 * 24 * 60 * 60 * 1000
const randomPastTime = now - Math.floor(Math.random() * twoDaysInMilliseconds)
return toUnixTimestamp(randomPastTime)
}
/**
* Generate nip44 conversation key
@ -297,19 +306,21 @@ export const countLeadingZeroes = (hex: string) => {
/**
* Function to create a wrapped event with PoW
* @param event Original event to be wrapped
* @param event Original event to be wrapped (can be unsigned or verified)
* @param receiver Public key of the receiver
* @param difficulty PoW difficulty level (default is 20)
* @returns
*/
//
export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => {
export const createWrap = (
event: UnsignedEvent | VerifiedEvent,
receiver: string
) => {
// Generate a random secret key and its corresponding public key
const randomKey = generateSecretKey()
const pubkey = getPublicKey(randomKey)
// Encrypt the event content using nip44 encryption
const content = nip44Encrypt(unsignedEvent, randomKey, receiver)
const content = nip44Encrypt(event, randomKey, receiver)
// Initialize nonce and leadingZeroes for PoW calculation
let nonce = 0
@ -320,11 +331,12 @@ export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => {
// eslint-disable-next-line no-constant-condition
while (true) {
// Create an unsigned event with the necessary fields
// TODO: kinds.GiftWrap (wrong kind number in nostr-tools 10/11/2024 at v2.7.2)
const event: UnsignedEvent = {
kind: 1059, // Event kind
content, // Encrypted content
pubkey, // Public key of the creator
created_at: unixNow(), // Current timestamp
created_at: randomTimeUpTo2DaysInThePast(),
tags: [
// Tags including receiver and nonce
['p', receiver],
@ -989,3 +1001,145 @@ export const getProfileUsername = (
truncate(profile?.display_name || profile?.name || hexToNpub(npub), {
length: 16
})
/**
* Modified {@link UnsignedEvent Unsigned Event} that includes an id
*
* Fields id and created_at are required.
* @see {@link UnsignedEvent}
* @see {@link https://github.com/nostr-protocol/nips/blob/master/17.md#direct-message-kind}
*/
type UnsignedEventWithId = UnsignedEvent & {
id?: string
}
export const sendPrivateDirectMessage = async (
message: string,
receiver: string,
subject?: string
) => {
// Instantiate the MetadataController to retrieve relay list metadata to look for preferred DM relays
const metadataController = MetadataController.getInstance()
const relaySet = await metadataController
.findRelayListMetadata(receiver)
.catch((err) => {
// Log an error if retrieving relay list metadata fails
console.log(
`An error occurred while finding relay list metadata for ${hexToNpub(receiver)}`,
err
)
return null
})
// Throw if metadata retrieval failed
if (!relaySet) {
throw new SendDMError(SendDMErrorType.METADATA_FETCH_FAILED, {
context: {
receiver
}
})
}
// Ensure relay list is not empty
if (relaySet.read.length === 0) {
throw new SendDMError(SendDMErrorType.RELAY_READ_EMPTY, {
context: {
receiver,
relaySet: JSON.stringify(relaySet)
}
})
}
// Get the direct message preferred relays list
// TODO: kinds.DirectMessageRelaysList (unavailabe in nostr-tools 10/10/2024 at v2.7.0)
// https://github.com/nostr-protocol/nips/blob/master/17.md#publishing
const eventFilter: Filter = {
kinds: [10050],
authors: [receiver]
}
const preferredRelaysListEvents =
await RelayController.getInstance().fetchEvents(eventFilter, relaySet.read)
const isRelayTag = (tag: string[]): boolean => tag[0] === 'relay'
const preferredRelaysList = preferredRelaysListEvents.reduce(
(previous: string[], current: Event) => {
const relaysList = current.tags
.filter((t) => isRelayTag(t) && !previous.includes(t[1]))
.map((t) => t[1])
return [...previous, ...relaysList]
},
[]
)
// Empty preferred relays list
const finalRelaysList: string[] =
preferredRelaysList?.length > 0 ? preferredRelaysList : [...relaySet.write]
// Generate "sender"
const senderSecret = generateSecretKey()
const senderPubkey = getPublicKey(senderSecret)
// Prepare tags for the message
const tags: string[][] = [['p', receiver]]
// Conversation title
if (subject) tags.push(['subject', subject])
// Create private DM event containing the message and relevant metadata
// TODO: kinds.PrivateDirectMessage (unavailabe in nostr-tools 10/10/2024 at v2.7.0)
const dm: UnsignedEventWithId = {
pubkey: senderPubkey,
created_at: unixNow(),
kind: 14,
tags,
content: message
}
// Calculate the hash based on the UnverifiedEvent
dm.id = getEventHash(dm)
// Encrypt the private dm using the sender secret and the receiver's public key
const encryptedDm = nip44Encrypt(dm, senderSecret, receiver)
if (!encryptedDm) {
throw new SendDMError(SendDMErrorType.ENCRYPTION_FAILED, {
context: {
receiver,
message,
kind: dm.kind
}
})
}
// Seal the message
// TODO: kinds.Seal (unavailabe in nostr-tools 10/10/2024 at v2.7.0)
const sealedMessage: UnsignedEvent = {
kind: 13, // seal
pubkey: senderPubkey,
content: encryptedDm,
created_at: randomTimeUpTo2DaysInThePast(),
tags: [] // no tags
}
// Finalize and sign the sealed event
const finalizedSeal = finalizeEvent(sealedMessage, senderSecret)
// Encrypt the seal and gift wrap
const finalizedGiftWrap = createWrap(finalizedSeal, receiver)
// Publish the finalized gift wrap event (the encrypted DM) to the relays
const publishedOnRelays = await relayController.publish(
finalizedGiftWrap,
finalRelaysList
)
// Handle cases where publishing to the relays failed
if (publishedOnRelays.length === 0) {
throw new SendDMError(SendDMErrorType.ENCRYPTION_FAILED, {
context: {
receiver,
count: publishedOnRelays.length
}
})
}
// Return true indicating that the DM was successfully sent
return true
}

View File

@ -1,119 +0,0 @@
import { OpenTimestamp, OpenTimestampUpgradeVerifyResponse } from '../types'
import { retry } from './retry.ts'
import { bytesToHex } from '@noble/hashes/utils'
import { utf8Encoder } from 'nostr-tools/utils'
import { hexStringToUint8Array } from './string.ts'
/**
* Generates a timestamp for the provided nostr event ID.
* @returns Timestamp with its value and the nostr event ID.
*/
export const generateTimestamp = async (
nostrId: string
): Promise<OpenTimestamp | undefined> => {
try {
return {
value: await retry(() => timestamp(nostrId)),
nostrId: nostrId
}
} catch (error) {
console.error(error)
return
}
}
/**
* Attempts to upgrade (i.e. add Bitcoin blockchain attestations) and verify the provided timestamp.
* Returns the same timestamp, alongside additional information required to decide if any further
* timestamp updates are required.
* @param timestamp
*/
export const upgradeAndVerifyTimestamp = async (
timestamp: OpenTimestamp
): Promise<OpenTimestampUpgradeVerifyResponse> => {
const upgradedResult = await upgrade(timestamp)
return await verify(upgradedResult)
}
/**
* Attempts to upgrade a timestamp. If an upgrade is available,
* it will add new data to detachedTimestamp.
* The upgraded flag indicates if an upgrade has been performed.
* @param t - timestamp
*/
export const upgrade = async (
t: OpenTimestamp
): Promise<OpenTimestampUpgradeVerifyResponse> => {
const detachedTimestamp =
window.OpenTimestamps.DetachedTimestampFile.deserialize(
hexStringToUint8Array(t.value)
)
const changed: boolean =
await window.OpenTimestamps.upgrade(detachedTimestamp)
if (changed) {
const updated = detachedTimestamp.serializeToBytes()
const value = {
...t,
timestamp: bytesToHex(updated)
}
return {
timestamp: value,
upgraded: true
}
}
return {
timestamp: t,
upgraded: false
}
}
/**
* Attempts to verify a timestamp. If verification is available,
* it will be included in the returned object.
* @param t - timestamp
*/
export const verify = async (
t: OpenTimestampUpgradeVerifyResponse
): Promise<OpenTimestampUpgradeVerifyResponse> => {
const detachedNostrId = window.OpenTimestamps.DetachedTimestampFile.fromBytes(
new window.OpenTimestamps.Ops.OpSHA256(),
utf8Encoder.encode(t.timestamp.nostrId)
)
const detachedTimestamp =
window.OpenTimestamps.DetachedTimestampFile.deserialize(
hexStringToUint8Array(t.timestamp.value)
)
const res = await window.OpenTimestamps.verify(
detachedTimestamp,
detachedNostrId
)
return {
...t,
verified: !!res.bitcoin,
verification: res?.bitcoin || null
}
}
/**
* Timestamps a nostrId.
* @param nostrId
*/
const timestamp = async (nostrId: string): Promise<string> => {
const detachedTimestamp =
window.OpenTimestamps.DetachedTimestampFile.fromBytes(
new window.OpenTimestamps.Ops.OpSHA256(),
utf8Encoder.encode(nostrId)
)
await window.OpenTimestamps.stamp(detachedTimestamp)
const ctx = new window.OpenTimestamps.Context.StreamSerialization()
detachedTimestamp.serialize(ctx)
const timestampBytes = ctx.getOutput()
return bytesToHex(timestampBytes)
}

View File

@ -1,4 +1,4 @@
import { MarkType, PdfPage } from '../types/drawing.ts'
import { PdfPage } from '../types/drawing.ts'
import { PDFDocument, PDFFont, PDFPage, rgb } from 'pdf-lib'
import { Mark } from '../types/mark.ts'
import * as PDFJS from 'pdfjs-dist'
@ -132,17 +132,9 @@ export const addMarks = async (
for (let i = 0; i < pages.length; i++) {
if (marksPerPage && Object.hasOwn(marksPerPage, i)) {
marksPerPage[i]?.forEach((mark) => {
switch (mark.type) {
case MarkType.SIGNATURE:
drawSignatureText(mark, pages[i])
break
default:
drawMarkText(mark, pages[i], robotoFont)
break
}
})
marksPerPage[i]?.forEach((mark) =>
drawMarkText(mark, pages[i], robotoFont)
)
}
}
@ -253,19 +245,3 @@ async function embedFont(pdf: PDFDocument) {
const embeddedFont = await pdf.embedFont(fontBytes)
return embeddedFont
}
const drawSignatureText = (mark: Mark, page: PDFPage) => {
const { location } = mark
const { height } = page.getSize()
// Convert the mark location origin (top, left) to PDF origin (bottom, left)
const x = location.left
const y = height - location.top
if (hasValue(mark)) {
const paths = JSON.parse(mark.value!)
paths.forEach((d: string) => {
page.drawSvgPath(d, { x, y })
})
}
}

View File

@ -97,20 +97,23 @@ const isOlderThanOneDay = (cachedAt: number) => {
}
const isRelayTag = (tag: string[]): boolean => tag[0] === 'r'
const addRelay = (list: string[], relay: string) => {
// Only add if the list doesn't already include the relay
if (!list.includes(relay)) list.push(relay)
}
const toRelaySet = (obj: RelaySet, tag: string[]): RelaySet => {
if (tag.length >= 3) {
const marker = tag[2]
if (marker === READ_MARKER) {
obj.read.push(tag[1])
addRelay(obj.read, tag[1])
} else if (marker === WRITE_MARKER) {
obj.write.push(tag[1])
addRelay(obj.write, tag[1])
}
}
if (tag.length === 2) {
obj.read.push(tag[1])
obj.write.push(tag[1])
addRelay(obj.read, tag[1])
addRelay(obj.write, tag[1])
}
return obj

View File

@ -1,25 +0,0 @@
export const retryAll = async <T>(
promises: (() => Promise<T>)[],
retries: number = 3,
delay: number = 1000
) => {
const wrappedPromises = promises.map((fn) => retry(fn, retries, delay))
return Promise.allSettled(wrappedPromises)
}
export const retry = async <T>(
fn: () => Promise<T>,
retries: number = 3,
delay: number = 1000
): Promise<T> => {
try {
return await fn()
} catch (err) {
if (retries === 0) {
return Promise.reject(err)
}
return new Promise((resolve) =>
setTimeout(() => resolve(retry(fn, retries - 1)), delay)
)
}
}

View File

@ -119,15 +119,3 @@ export const settleAllFullfilfedPromises = async <Item, FulfilledItem = Item>(
return acc
}, [])
}
export const isPromiseFulfilled = <T>(
result: PromiseSettledResult<T>
): result is PromiseFulfilledResult<T> => {
return result.status === 'fulfilled'
}
export const isPromiseRejected = <T>(
result: PromiseSettledResult<T>
): result is PromiseRejectedResult => {
return result.status === 'rejected'
}

View File

@ -1,16 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
export default defineConfig({
plugins: [
react(),
tsconfigPaths(),
nodePolyfills({
include: ['os']
})
],
plugins: [react(), tsconfigPaths()],
build: {
target: 'ES2022'
}