New release #210

Merged
b merged 28 commits from staging into main 2024-09-19 08:23:14 +00:00
37 changed files with 609 additions and 708 deletions

10
package-lock.json generated
View File

@ -21,6 +21,7 @@
"@mui/material": "5.15.11", "@mui/material": "5.15.11",
"@noble/hashes": "^1.4.0", "@noble/hashes": "^1.4.0",
"@nostr-dev-kit/ndk": "2.5.0", "@nostr-dev-kit/ndk": "2.5.0",
"@pdf-lib/fontkit": "^1.1.1",
"@reduxjs/toolkit": "2.2.1", "@reduxjs/toolkit": "2.2.1",
"axios": "^1.7.4", "axios": "^1.7.4",
"crypto-hash": "3.0.0", "crypto-hash": "3.0.0",
@ -1749,6 +1750,15 @@
} }
} }
}, },
"node_modules/@pdf-lib/fontkit": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz",
"integrity": "sha512-KjMd7grNapIWS/Dm0gvfHEilSyAmeLvrEGVcqLGi0VYebuqqzTbgF29efCx7tvx+IEbG3zQciRSWl3GkUSvjZg==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/standard-fonts": { "node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",

View File

@ -31,6 +31,7 @@
"@mui/material": "5.15.11", "@mui/material": "5.15.11",
"@noble/hashes": "^1.4.0", "@noble/hashes": "^1.4.0",
"@nostr-dev-kit/ndk": "2.5.0", "@nostr-dev-kit/ndk": "2.5.0",
"@pdf-lib/fontkit": "^1.1.1",
"@reduxjs/toolkit": "2.2.1", "@reduxjs/toolkit": "2.2.1",
"axios": "^1.7.4", "axios": "^1.7.4",
"crypto-hash": "3.0.0", "crypto-hash": "3.0.0",

View File

@ -135,12 +135,18 @@ li {
// Consistent styling for every file mark // Consistent styling for every file mark
// Reverts some of the design defaults for font // Reverts some of the design defaults for font
.file-mark { .file-mark {
font-family: Arial; font-family: 'Roboto';
font-size: 16px; font-style: normal;
font-weight: normal; font-weight: normal;
color: black;
letter-spacing: normal; letter-spacing: normal;
border: 1px solid transparent; line-height: 1;
font-size: 16px;
color: black;
outline: 1px solid transparent;
justify-content: start;
align-items: start;
scroll-margin-top: $header-height + $body-vertical-padding; scroll-margin-top: $header-height + $body-vertical-padding;
} }

Binary file not shown.

View File

@ -37,7 +37,7 @@ import { setUserRobotImage } from '../../store/userRobotImage/action'
import { Container } from '../Container' import { Container } from '../Container'
import { ButtonIcon } from '../ButtonIcon' import { ButtonIcon } from '../ButtonIcon'
const metadataController = new MetadataController() const metadataController = MetadataController.getInstance()
export const AppBar = () => { export const AppBar = () => {
const navigate = useNavigate() const navigate = useNavigate()

View File

@ -1,7 +1,7 @@
import { Meta } from '../../types' import { Meta } from '../../types'
import { SigitCardDisplayInfo, SigitStatus, SignStatus } from '../../utils' import { SigitCardDisplayInfo, SigitStatus, SignStatus } from '../../utils'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { formatTimestamp, hexToNpub, npubToHex, shorten } from '../../utils' import { formatTimestamp, npubToHex } from '../../utils'
import { appPublicRoutes, appPrivateRoutes } from '../../routes' import { appPublicRoutes, appPrivateRoutes } from '../../routes'
import { Button, Divider, Tooltip } from '@mui/material' import { Button, Divider, Tooltip } from '@mui/material'
import { DisplaySigner } from '../DisplaySigner' import { DisplaySigner } from '../DisplaySigner'
@ -17,9 +17,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { UserAvatarGroup } from '../UserAvatarGroup' import { UserAvatarGroup } from '../UserAvatarGroup'
import styles from './style.module.scss' import styles from './style.module.scss'
import { TooltipChild } from '../TooltipChild'
import { getExtensionIconLabel } from '../getExtensionIconLabel' import { getExtensionIconLabel } from '../getExtensionIconLabel'
import { useSigitProfiles } from '../../hooks/useSigitProfiles'
import { useSigitMeta } from '../../hooks/useSigitMeta' import { useSigitMeta } from '../../hooks/useSigitMeta'
import { extractFileExtensions } from '../../utils/file' import { extractFileExtensions } from '../../utils/file'
@ -33,12 +31,6 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
parsedMeta parsedMeta
const { signersStatus, fileHashes } = useSigitMeta(meta) const { signersStatus, fileHashes } = useSigitMeta(meta)
const profiles = useSigitProfiles([
...(submittedBy ? [submittedBy] : []),
...signers
])
const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes)) const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes))
return ( return (
@ -54,62 +46,29 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
></Link> ></Link>
<p className={`line-clamp-2 ${styles.title}`}>{title}</p> <p className={`line-clamp-2 ${styles.title}`}>{title}</p>
<div className={styles.users}> <div className={styles.users}>
{submittedBy && {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 <DisplaySigner
status={isValid ? SignStatus.Signed : SignStatus.Invalid} status={isValid ? SignStatus.Signed : SignStatus.Invalid}
profile={profile}
pubkey={submittedBy} pubkey={submittedBy}
/> />
</TooltipChild> )}
</Tooltip>
)
})()}
{submittedBy && signers.length ? ( {submittedBy && signers.length ? (
<Divider orientation="vertical" flexItem /> <Divider orientation="vertical" flexItem />
) : null} ) : null}
<UserAvatarGroup max={7}> <UserAvatarGroup max={7}>
{signers.map((signer) => { {signers.map((signer) => {
const pubkey = npubToHex(signer)! const pubkey = npubToHex(signer)!
const profile = profiles[pubkey]
return ( return (
<Tooltip
key={signer}
title={
profile?.display_name || profile?.name || shorten(pubkey)
}
placement="top"
arrow
disableInteractive
>
<TooltipChild>
<DisplaySigner <DisplaySigner
key={pubkey}
status={signersStatus[signer]} status={signersStatus[signer]}
profile={profile}
pubkey={pubkey} pubkey={pubkey}
/> />
</TooltipChild>
</Tooltip>
) )
})} })}
</UserAvatarGroup> </UserAvatarGroup>
</div> </div>
<div className={`${styles.details} ${styles.date} ${styles.iconLabel}`}> <div className={`${styles.details} ${styles.iconLabel}`}>
<FontAwesomeIcon icon={faCalendar} /> <FontAwesomeIcon icon={faCalendar} />
{createdAt ? formatTimestamp(createdAt) : null} {createdAt ? formatTimestamp(createdAt) : null}
</div> </div>

View File

@ -1,5 +1,4 @@
import { Badge } from '@mui/material' import { Badge } from '@mui/material'
import { ProfileMetadata } from '../../types'
import styles from './style.module.scss' import styles from './style.module.scss'
import { UserAvatar } from '../UserAvatar' import { UserAvatar } from '../UserAvatar'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
@ -15,16 +14,10 @@ import { SignStatus } from '../../utils'
import { Spinner } from '../Spinner' import { Spinner } from '../Spinner'
type DisplaySignerProps = { type DisplaySignerProps = {
profile: ProfileMetadata
pubkey: string pubkey: string
status: SignStatus status: SignStatus
} }
export const DisplaySigner = ({
status,
profile,
pubkey
}: DisplaySignerProps) => {
const getStatusIcon = (status: SignStatus) => { const getStatusIcon = (status: SignStatus) => {
switch (status) { switch (status) {
case SignStatus.Signed: case SignStatus.Signed:
@ -47,6 +40,7 @@ export const DisplaySigner = ({
} }
} }
export const DisplaySigner = ({ status, pubkey }: DisplaySignerProps) => {
return ( return (
<Badge <Badge
className={styles.signer} className={styles.signer}
@ -56,7 +50,7 @@ export const DisplaySigner = ({
<div className={styles.statusBadge}>{getStatusIcon(status)}</div> <div className={styles.statusBadge}>{getStatusIcon(status)}</div>
} }
> >
<UserAvatar pubkey={pubkey} image={profile?.picture} /> <UserAvatar pubkey={pubkey} />
</Badge> </Badge>
) )
} }

View File

@ -14,9 +14,10 @@ import { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing'
import { truncate } from 'lodash' import { truncate } from 'lodash'
import { settleAllFullfilfedPromises, hexToNpub, npubToHex } from '../../utils' import { settleAllFullfilfedPromises, hexToNpub, npubToHex } from '../../utils'
import { getSigitFile, SigitFile } from '../../utils/file' import { getSigitFile, SigitFile } from '../../utils/file'
import { getToolboxLabelByMarkType } from '../../utils/mark'
import { FileDivider } from '../FileDivider' import { FileDivider } from '../FileDivider'
import { ExtensionFileBox } from '../ExtensionFileBox' import { ExtensionFileBox } from '../ExtensionFileBox'
import { inPx } from '../../utils/pdf' import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf'
import { useScale } from '../../hooks/useScale' import { useScale } from '../../hooks/useScale'
import { AvatarIconButton } from '../UserAvatarIconButton' import { AvatarIconButton } from '../UserAvatarIconButton'
import { LoadingSpinner } from '../LoadingSpinner' import { LoadingSpinner } from '../LoadingSpinner'
@ -390,7 +391,7 @@ export const DrawPDFFields = (props: Props) => {
backgroundColor: drawnField.counterpart backgroundColor: drawnField.counterpart
? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}4b` ? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}4b`
: undefined, : undefined,
borderColor: drawnField.counterpart outlineColor: drawnField.counterpart
? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}` ? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}`
: undefined, : undefined,
left: inPx(from(page.width, drawnField.left)), left: inPx(from(page.width, drawnField.left)),
@ -406,6 +407,16 @@ export const DrawPDFFields = (props: Props) => {
: undefined : undefined
}} }}
> >
<div
className={`file-mark ${styles.placeholder}`}
style={{
fontFamily: FONT_TYPE,
fontSize: inPx(from(page.width, FONT_SIZE))
}}
>
{getToolboxLabelByMarkType(drawnField.type) ||
'placeholder'}
</div>
<span <span
onPointerDown={(event) => onPointerDown={(event) =>
handleResizePointerDown(event, drawnFieldIndex) handleResizePointerDown(event, drawnFieldIndex)

View File

@ -13,9 +13,15 @@
} }
} }
.placeholder {
position: absolute;
opacity: 0.5;
inset: 0;
}
.drawingRectangle { .drawingRectangle {
position: absolute; position: absolute;
border: 1px solid #01aaad; outline: 1px solid #01aaad;
z-index: 50; z-index: 50;
background-color: #01aaad4b; background-color: #01aaad4b;
cursor: pointer; cursor: pointer;
@ -29,7 +35,7 @@
} }
&.edited { &.edited {
border: 1px dotted #01aaad; outline: 1px dotted #01aaad;
} }
.resizeHandle { .resizeHandle {

View File

@ -1,12 +1,14 @@
import { createPortal } from 'react-dom'
import styles from './style.module.scss' import styles from './style.module.scss'
import { PropsWithChildren } from 'react'
interface Props { interface Props {
desc?: string desc?: string
variant?: 'small' | 'default' variant?: 'small' | 'default'
} }
export const LoadingSpinner = (props: Props) => { export const LoadingSpinner = (props: PropsWithChildren<Props>) => {
const { desc, variant = 'default' } = props const { desc, children, variant = 'default' } = props
switch (variant) { switch (variant) {
case 'small': case 'small':
@ -20,16 +22,22 @@ export const LoadingSpinner = (props: Props) => {
) )
default: default:
return ( return createPortal(
<div className={styles.loadingSpinnerOverlay}> <div className={styles.loadingSpinnerOverlay}>
<div <div
className={styles.loadingSpinnerContainer} className={styles.loadingSpinnerContainer}
data-variant={variant} data-variant={variant}
> >
<div className={styles.loadingSpinner}></div> <div className={styles.loadingSpinner}></div>
{desc && <p className={styles.loadingSpinnerDesc}>{desc}</p>} {desc && (
<div className={styles.loadingSpinnerDesc}>
{desc}
{children}
</div> </div>
)}
</div> </div>
</div>,
document.getElementById('root')!
) )
} }
} }

View File

@ -42,11 +42,15 @@
width: 100%; width: 100%;
padding: 15px; padding: 15px;
border-top: solid 1px rgba(0, 0, 0, 0.1); border-top: solid 1px rgba(0, 0, 0, 0.1);
text-align: center;
color: rgba(0, 0, 0, 0.5); color: rgba(0, 0, 0, 0.5);
font-size: 16px; font-size: 16px;
font-weight: 400; font-weight: 400;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
} }
@keyframes spin { @keyframes spin {

View File

@ -36,7 +36,7 @@ const PdfMarkItem = forwardRef<HTMLDivElement, PdfMarkItemProps>(
backgroundColor: selectedMark?.mark.npub backgroundColor: selectedMark?.mark.npub
? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}4b` ? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}4b`
: undefined, : undefined,
borderColor: selectedMark?.mark.npub outlineColor: selectedMark?.mark.npub
? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}` ? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}`
: undefined, : undefined,
left: inPx(from(pageWidth, location.left)), left: inPx(from(pageWidth, location.left)),

View File

@ -3,34 +3,56 @@ import { getProfileRoute } from '../../routes'
import styles from './styles.module.scss' import styles from './styles.module.scss'
import { AvatarIconButton } from '../UserAvatarIconButton' import { AvatarIconButton } from '../UserAvatarIconButton'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useProfileMetadata } from '../../hooks/useProfileMetadata'
import { Tooltip } from '@mui/material'
import { shorten } from '../../utils'
import { TooltipChild } from '../TooltipChild'
interface UserAvatarProps { interface UserAvatarProps {
name?: string
pubkey: string pubkey: string
image?: string isNameVisible?: boolean
} }
/** /**
* This component will be used for the displaying username and profile picture. * This component will be used for the displaying username and profile picture.
* Clicking will navigate to the user's profile. * Clicking will navigate to the user's profile.
*/ */
export const UserAvatar = ({ pubkey, name, image }: UserAvatarProps) => { export const UserAvatar = ({
pubkey,
isNameVisible = false
}: UserAvatarProps) => {
const profile = useProfileMetadata(pubkey)
const name = profile?.display_name || profile?.name || shorten(pubkey)
const image = profile?.picture
return ( return (
<Link <Link
to={getProfileRoute(pubkey)} to={getProfileRoute(pubkey)}
className={styles.container} className={styles.container}
tabIndex={-1} tabIndex={-1}
> >
<Tooltip
key={pubkey}
title={name}
placement="top"
arrow
disableInteractive
>
<TooltipChild>
<AvatarIconButton <AvatarIconButton
src={image} src={image}
hexKey={pubkey} hexKey={pubkey}
aria-label={`account of user ${name || pubkey}`} aria-label={`account of user ${name}`}
color="inherit" color="inherit"
sx={{ sx={{
padding: 0 padding: 0
}} }}
/> />
{name ? <span className={styles.username}>{name}</span> : null} </TooltipChild>
</Tooltip>
{isNameVisible && name ? (
<span className={styles.username}>{name}</span>
) : null}
</Link> </Link>
) )
} }

View File

@ -1,11 +1,9 @@
import { Divider, Tooltip } from '@mui/material' import { Divider, Tooltip } from '@mui/material'
import { useSigitProfiles } from '../../hooks/useSigitProfiles'
import { import {
formatTimestamp, formatTimestamp,
fromUnixTimestamp, fromUnixTimestamp,
hexToNpub, hexToNpub,
npubToHex, npubToHex,
shorten,
SignStatus SignStatus
} from '../../utils' } from '../../utils'
import { useSigitMeta } from '../../hooks/useSigitMeta' import { useSigitMeta } from '../../hooks/useSigitMeta'
@ -24,10 +22,10 @@ import {
import { getExtensionIconLabel } from '../getExtensionIconLabel' import { getExtensionIconLabel } from '../getExtensionIconLabel'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { State } from '../../store/rootReducer' import { State } from '../../store/rootReducer'
import { TooltipChild } from '../TooltipChild'
import { DisplaySigner } from '../DisplaySigner' import { DisplaySigner } from '../DisplaySigner'
import { Meta } from '../../types' import { Meta } from '../../types'
import { extractFileExtensions } from '../../utils/file' import { extractFileExtensions } from '../../utils/file'
import { UserAvatar } from '../UserAvatar'
interface UsersDetailsProps { interface UsersDetailsProps {
meta: Meta meta: Meta
@ -36,6 +34,7 @@ interface UsersDetailsProps {
export const UsersDetails = ({ meta }: UsersDetailsProps) => { export const UsersDetails = ({ meta }: UsersDetailsProps) => {
const { const {
submittedBy, submittedBy,
exportedBy,
signers, signers,
viewers, viewers,
fileHashes, fileHashes,
@ -47,11 +46,6 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
isValid isValid
} = useSigitMeta(meta) } = useSigitMeta(meta)
const { usersPubkey } = useSelector((state: State) => state.auth) const { usersPubkey } = useSelector((state: State) => state.auth)
const profiles = useSigitProfiles([
...(submittedBy ? [submittedBy] : []),
...signers,
...viewers
])
const userCanSign = const userCanSign =
typeof usersPubkey !== 'undefined' && typeof usersPubkey !== 'undefined' &&
signers.includes(hexToNpub(usersPubkey)) signers.includes(hexToNpub(usersPubkey))
@ -63,31 +57,12 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
<div className={styles.section}> <div className={styles.section}>
<p>Signers</p> <p>Signers</p>
<div className={styles.users}> <div className={styles.users}>
{submittedBy && {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 <DisplaySigner
status={isValid ? SignStatus.Signed : SignStatus.Invalid} status={isValid ? SignStatus.Signed : SignStatus.Invalid}
profile={profile}
pubkey={submittedBy} pubkey={submittedBy}
/> />
</TooltipChild> )}
</Tooltip>
)
})()}
{submittedBy && signers.length ? ( {submittedBy && signers.length ? (
<Divider orientation="vertical" flexItem /> <Divider orientation="vertical" flexItem />
@ -96,26 +71,12 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
<UserAvatarGroup max={20}> <UserAvatarGroup max={20}>
{signers.map((signer) => { {signers.map((signer) => {
const pubkey = npubToHex(signer)! const pubkey = npubToHex(signer)!
const profile = profiles[pubkey]
return ( return (
<Tooltip
key={signer}
title={
profile?.display_name || profile?.name || shorten(pubkey)
}
placement="top"
arrow
disableInteractive
>
<TooltipChild>
<DisplaySigner <DisplaySigner
key={pubkey}
status={signersStatus[signer]} status={signersStatus[signer]}
profile={profile}
pubkey={pubkey} pubkey={pubkey}
/> />
</TooltipChild>
</Tooltip>
) )
})} })}
</UserAvatarGroup> </UserAvatarGroup>
@ -128,34 +89,28 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
<UserAvatarGroup max={20}> <UserAvatarGroup max={20}>
{viewers.map((signer) => { {viewers.map((signer) => {
const pubkey = npubToHex(signer)! const pubkey = npubToHex(signer)!
const profile = profiles[pubkey]
return ( return (
<Tooltip
key={signer}
title={
profile?.display_name ||
profile?.name ||
shorten(pubkey)
}
placement="top"
arrow
disableInteractive
>
<TooltipChild>
<DisplaySigner <DisplaySigner
key={pubkey}
status={SignStatus.Viewer} status={SignStatus.Viewer}
profile={profile}
pubkey={pubkey} pubkey={pubkey}
/> />
</TooltipChild>
</Tooltip>
) )
})} })}
</UserAvatarGroup> </UserAvatarGroup>
</div> </div>
</> </>
)} )}
{exportedBy && (
<>
<p>Exported By</p>
<div className={styles.users}>
<UserAvatar pubkey={exportedBy} />
</div>
</>
)}
</div> </div>
<div className={styles.section}> <div className={styles.section}>
<p>Details</p> <p>Details</p>

View File

@ -25,7 +25,7 @@ export class AuthController {
constructor() { constructor() {
this.nostrController = NostrController.getInstance() this.nostrController = NostrController.getInstance()
this.metadataController = new MetadataController() this.metadataController = MetadataController.getInstance()
} }
/** /**

View File

@ -22,6 +22,7 @@ import {
import { DEFAULT_LOOK_UP_RELAY_LIST } from '../utils/const' import { DEFAULT_LOOK_UP_RELAY_LIST } from '../utils/const'
export class MetadataController extends EventEmitter { export class MetadataController extends EventEmitter {
private static instance: MetadataController
private nostrController: NostrController private nostrController: NostrController
private specialMetadataRelay = 'wss://purplepag.es' private specialMetadataRelay = 'wss://purplepag.es'
private pendingFetches = new Map<string, Promise<Event | null>>() // Track pending fetches private pendingFetches = new Map<string, Promise<Event | null>>() // Track pending fetches
@ -31,6 +32,13 @@ export class MetadataController extends EventEmitter {
this.nostrController = NostrController.getInstance() this.nostrController = NostrController.getInstance()
} }
public static getInstance(): MetadataController {
if (!MetadataController.instance) {
MetadataController.instance = new MetadataController()
}
return MetadataController.instance
}
/** /**
* Asynchronously checks for more recent metadata events authored by a specific key. * Asynchronously checks for more recent metadata events authored by a specific key.
* If a more recent metadata event is found, it is handled and returned. * If a more recent metadata event is found, it is handled and returned.
@ -119,7 +127,6 @@ export class MetadataController extends EventEmitter {
// Check if the cached metadata is older than one day // Check if the cached metadata is older than one day
if (isOlderThanOneDay(cachedMetadataEvent.cachedAt)) { if (isOlderThanOneDay(cachedMetadataEvent.cachedAt)) {
// If older than one week, find the metadata from relays in background // If older than one week, find the metadata from relays in background
this.checkForMoreRecentMetadata(hexKey, cachedMetadataEvent.event) this.checkForMoreRecentMetadata(hexKey, cachedMetadataEvent.event)
} }

View File

@ -0,0 +1,46 @@
import { useEffect, useState } from 'react'
import { ProfileMetadata } from '../types/profile'
import { MetadataController } from '../controllers/MetadataController'
import { Event, kinds } from 'nostr-tools'
export const useProfileMetadata = (pubkey: string) => {
const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>()
useEffect(() => {
const metadataController = MetadataController.getInstance()
const handleMetadataEvent = (event: Event) => {
const metadataContent =
metadataController.extractProfileMetadataContent(event)
if (metadataContent) {
setProfileMetadata(metadataContent)
}
}
if (pubkey) {
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: ${pubkey}`,
err
)
})
}
return () => {
metadataController.off(pubkey, handleMetadataEvent)
}
}, [pubkey])
return profileMetadata
}

View File

@ -33,6 +33,10 @@ export interface FlatMeta
// Remove pubkey and use submittedBy as `npub1${string}` // Remove pubkey and use submittedBy as `npub1${string}`
submittedBy?: `npub1${string}` submittedBy?: `npub1${string}`
// Optional field only present on exported sigits
// Exporting adds user's pubkey
exportedBy?: `npub1${string}`
// Remove created_at and replace with createdAt // Remove created_at and replace with createdAt
createdAt?: number createdAt?: number
@ -68,6 +72,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
const [tags, setTags] = useState<string[][]>() const [tags, setTags] = useState<string[][]>()
const [createdAt, setCreatedAt] = useState<number>() const [createdAt, setCreatedAt] = useState<number>()
const [submittedBy, setSubmittedBy] = useState<`npub1${string}`>() // submittedBy, pubkey from nostr event const [submittedBy, setSubmittedBy] = useState<`npub1${string}`>() // submittedBy, pubkey from nostr event
const [exportedBy, setExportedBy] = useState<`npub1${string}`>() // pubkey from export signature nostr event
const [id, setId] = useState<string>() const [id, setId] = useState<string>()
const [sig, setSig] = useState<string>() const [sig, setSig] = useState<string>()
@ -99,6 +104,18 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
if (!meta) return if (!meta) return
;(async function () { ;(async function () {
try { try {
if (meta.exportSignature) {
const exportSignatureEvent = await parseNostrEvent(
meta.exportSignature
)
if (
verifyEvent(exportSignatureEvent) &&
exportSignatureEvent.pubkey
) {
setExportedBy(exportSignatureEvent.pubkey as `npub1${string}`)
}
}
const createSignatureEvent = await parseNostrEvent(meta.createSignature) const createSignatureEvent = await parseNostrEvent(meta.createSignature)
const { kind, tags, created_at, pubkey, id, sig, content } = const { kind, tags, created_at, pubkey, id, sig, content } =
@ -265,6 +282,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
tags, tags,
createdAt, createdAt,
submittedBy, submittedBy,
exportedBy,
id, id,
sig, sig,
signers, signers,

View File

@ -1,71 +0,0 @@
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))
})
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pubkeys])
return profileMetadata
}

View File

@ -38,7 +38,7 @@ export const MainLayout = () => {
const hasSubscribed = useRef(false) const hasSubscribed = useRef(false)
useEffect(() => { useEffect(() => {
const metadataController = new MetadataController() const metadataController = MetadataController.getInstance()
const logout = () => { const logout = () => {
dispatch( dispatch(

View File

@ -36,44 +36,26 @@ import {
npubToHex, npubToHex,
queryNip05, queryNip05,
sendNotification, sendNotification,
shorten,
signEventForMetaFile, signEventForMetaFile,
updateUsersAppData, updateUsersAppData,
uploadToFileStorage uploadToFileStorage,
DEFAULT_TOOLBOX
} from '../../utils' } from '../../utils'
import { Container } from '../../components/Container' import { Container } from '../../components/Container'
import fileListStyles from '../../components/FileList/style.module.scss' import fileListStyles from '../../components/FileList/style.module.scss'
import { DrawTool, MarkType } from '../../types/drawing' import { DrawTool } from '../../types/drawing'
import { DrawPDFFields } from '../../components/DrawPDFFields' import { DrawPDFFields } from '../../components/DrawPDFFields'
import { Mark } from '../../types/mark.ts' import { Mark } from '../../types/mark.ts'
import { StickySideColumns } from '../../layouts/StickySideColumns.tsx' import { StickySideColumns } from '../../layouts/StickySideColumns.tsx'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { import {
fa1,
faBriefcase,
faCalendarDays,
faCheckDouble,
faCircleDot,
faClock,
faCreditCard,
faEllipsis, faEllipsis,
faEye, faEye,
faFile, faFile,
faFileCirclePlus, faFileCirclePlus,
faGripLines, faGripLines,
faHeading,
faIdCard,
faImage,
faPaperclip,
faPen, faPen,
faPhone,
faPlus, faPlus,
faSignature,
faSquareCaretDown,
faSquareCheck,
faStamp,
faT,
faTableCellsLarge,
faToolbox, faToolbox,
faTrash, faTrash,
faUpload faUpload
@ -113,6 +95,8 @@ export const CreatePage = () => {
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
const [users, setUsers] = useState<User[]>([]) const [users, setUsers] = useState<User[]>([])
const signers = users.filter((u) => u.role === UserRole.signer)
const viewers = users.filter((u) => u.role === UserRole.viewer)
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey) const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
@ -124,116 +108,7 @@ export const CreatePage = () => {
const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([]) const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([])
const [selectedTool, setSelectedTool] = useState<DrawTool>() const [selectedTool, setSelectedTool] = useState<DrawTool>()
const [toolbox] = useState<DrawTool[]>([ const [toolbox] = useState<DrawTool[]>(DEFAULT_TOOLBOX)
{
identifier: MarkType.TEXT,
icon: faT,
label: 'Text',
active: true
},
{
identifier: MarkType.SIGNATURE,
icon: faSignature,
label: 'Signature',
active: false
},
{
identifier: MarkType.JOBTITLE,
icon: faBriefcase,
label: 'Job Title',
active: false
},
{
identifier: MarkType.FULLNAME,
icon: faIdCard,
label: 'Full Name',
active: false
},
{
identifier: MarkType.INITIALS,
icon: faHeading,
label: 'Initials',
active: false
},
{
identifier: MarkType.DATETIME,
icon: faClock,
label: 'Date Time',
active: false
},
{
identifier: MarkType.DATE,
icon: faCalendarDays,
label: 'Date',
active: false
},
{
identifier: MarkType.NUMBER,
icon: fa1,
label: 'Number',
active: false
},
{
identifier: MarkType.IMAGES,
icon: faImage,
label: 'Images',
active: false
},
{
identifier: MarkType.CHECKBOX,
icon: faSquareCheck,
label: 'Checkbox',
active: false
},
{
identifier: MarkType.MULTIPLE,
icon: faCheckDouble,
label: 'Multiple',
active: false
},
{
identifier: MarkType.FILE,
icon: faPaperclip,
label: 'File',
active: false
},
{
identifier: MarkType.RADIO,
icon: faCircleDot,
label: 'Radio',
active: false
},
{
identifier: MarkType.SELECT,
icon: faSquareCaretDown,
label: 'Select',
active: false
},
{
identifier: MarkType.CELLS,
icon: faTableCellsLarge,
label: 'Cells',
active: false
},
{
identifier: MarkType.STAMP,
icon: faStamp,
label: 'Stamp',
active: false
},
{
identifier: MarkType.PAYMENT,
icon: faCreditCard,
label: 'Payment',
active: false
},
{
identifier: MarkType.PHONE,
icon: faPhone,
label: 'Phone',
active: false
}
])
/** /**
* Changes the drawing tool * Changes the drawing tool
@ -252,7 +127,7 @@ export const CreatePage = () => {
useEffect(() => { useEffect(() => {
users.forEach((user) => { users.forEach((user) => {
if (!(user.pubkey in metadata)) { if (!(user.pubkey in metadata)) {
const metadataController = new MetadataController() const metadataController = MetadataController.getInstance()
const handleMetadataEvent = (event: Event) => { const handleMetadataEvent = (event: Event) => {
const metadataContent = const metadataContent =
@ -647,6 +522,11 @@ export const CreatePage = () => {
} }
saveAs(finalZipFile, `request-${unixNow()}.sigit.zip`) saveAs(finalZipFile, `request-${unixNow()}.sigit.zip`)
// If user is the next signer, we can navigate directly to sign page
if (signers[0].pubkey === usersPubkey) {
navigate(appPrivateRoutes.sign, { state: { uploadedZip: finalZipFile } })
}
setIsLoading(false) setIsLoading(false)
} }
@ -672,9 +552,6 @@ export const CreatePage = () => {
}, },
zipUrl: string zipUrl: string
) => { ) => {
const signers = users.filter((user) => user.role === UserRole.signer)
const viewers = users.filter((user) => user.role === UserRole.viewer)
const content: CreateSignatureEventContent = { const content: CreateSignatureEventContent = {
signers: signers.map((signer) => hexToNpub(signer.pubkey)), signers: signers.map((signer) => hexToNpub(signer.pubkey)),
viewers: viewers.map((viewer) => hexToNpub(viewer.pubkey)), viewers: viewers.map((viewer) => hexToNpub(viewer.pubkey)),
@ -703,9 +580,6 @@ export const CreatePage = () => {
// Send notifications to signers and viewers // Send notifications to signers and viewers
const sendNotifications = (meta: Meta) => { const sendNotifications = (meta: Meta) => {
const signers = users.filter((user) => user.role === UserRole.signer)
const viewers = users.filter((user) => user.role === UserRole.viewer)
// no need to send notification to self so remove it from the list // no need to send notification to self so remove it from the list
const receivers = ( const receivers = (
signers.length > 0 signers.length > 0
@ -787,7 +661,7 @@ export const CreatePage = () => {
toast.error('Failed to publish notifications') toast.error('Failed to publish notifications')
}) })
navigate(appPrivateRoutes.sign, { state: { meta: meta } }) navigate(appPrivateRoutes.sign, { state: { meta } })
} else { } else {
const zip = new JSZip() const zip = new JSZip()
@ -914,7 +788,6 @@ export const CreatePage = () => {
<div className={styles.flexWrap}> <div className={styles.flexWrap}>
<div className={`${styles.paperGroup} ${styles.users}`}> <div className={`${styles.paperGroup} ${styles.users}`}>
<DisplayUser <DisplayUser
metadata={metadata}
users={users} users={users}
handleUserRoleChange={handleUserRoleChange} handleUserRoleChange={handleUserRoleChange}
handleRemoveUser={handleRemoveUser} handleRemoveUser={handleRemoveUser}
@ -958,9 +831,6 @@ export const CreatePage = () => {
<FontAwesomeIcon icon={faPlus} /> <FontAwesomeIcon icon={faPlus} />
</Button> </Button>
</div> </div>
<Button onClick={handleCreate} variant="contained">
Publish
</Button>
<div className={`${styles.paperGroup} ${styles.toolbox}`}> <div className={`${styles.paperGroup} ${styles.toolbox}`}>
{toolbox.map((drawTool: DrawTool, index: number) => { {toolbox.map((drawTool: DrawTool, index: number) => {
@ -987,6 +857,10 @@ export const CreatePage = () => {
})} })}
</div> </div>
<Button onClick={handleCreate} variant="contained">
Publish
</Button>
{!!error && ( {!!error && (
<FormHelperText error={!!error}>{error}</FormHelperText> <FormHelperText error={!!error}>{error}</FormHelperText>
)} )}
@ -1010,7 +884,6 @@ export const CreatePage = () => {
} }
type DisplayUsersProps = { type DisplayUsersProps = {
metadata: { [key: string]: ProfileMetadata }
users: User[] users: User[]
handleUserRoleChange: (role: UserRole, pubkey: string) => void handleUserRoleChange: (role: UserRole, pubkey: string) => void
handleRemoveUser: (pubkey: string) => void handleRemoveUser: (pubkey: string) => void
@ -1018,7 +891,6 @@ type DisplayUsersProps = {
} }
const DisplayUser = ({ const DisplayUser = ({
metadata,
users, users,
handleUserRoleChange, handleUserRoleChange,
handleRemoveUser, handleRemoveUser,
@ -1032,7 +904,6 @@ const DisplayUser = ({
.map((user, index) => ( .map((user, index) => (
<SignerCounterpart <SignerCounterpart
key={`signer-${user.pubkey}`} key={`signer-${user.pubkey}`}
userMeta={metadata[user.pubkey]}
user={user} user={user}
index={index} index={index}
moveSigner={moveSigner} moveSigner={moveSigner}
@ -1047,7 +918,6 @@ const DisplayUser = ({
return ( return (
<div className={styles.user} key={`viewer-${user.pubkey}`}> <div className={styles.user} key={`viewer-${user.pubkey}`}>
<Counterpart <Counterpart
userMeta={metadata[user.pubkey]}
user={user} user={user}
handleUserRoleChange={handleUserRoleChange} handleUserRoleChange={handleUserRoleChange}
handleRemoveUser={handleRemoveUser} handleRemoveUser={handleRemoveUser}
@ -1066,7 +936,6 @@ interface DragItem {
} }
type CounterpartProps = { type CounterpartProps = {
userMeta: ProfileMetadata
user: User user: User
handleUserRoleChange: (role: UserRole, pubkey: string) => void handleUserRoleChange: (role: UserRole, pubkey: string) => void
handleRemoveUser: (pubkey: string) => void handleRemoveUser: (pubkey: string) => void
@ -1078,7 +947,6 @@ type SignerCounterpartProps = CounterpartProps & {
} }
const SignerCounterpart = ({ const SignerCounterpart = ({
userMeta,
user, user,
index, index,
moveSigner, moveSigner,
@ -1171,7 +1039,6 @@ const SignerCounterpart = ({
<FontAwesomeIcon width={'14px'} fontSize={'14px'} icon={faGripLines} /> <FontAwesomeIcon width={'14px'} fontSize={'14px'} icon={faGripLines} />
<Counterpart <Counterpart
user={user} user={user}
userMeta={userMeta}
handleRemoveUser={handleRemoveUser} handleRemoveUser={handleRemoveUser}
handleUserRoleChange={handleUserRoleChange} handleUserRoleChange={handleUserRoleChange}
/> />
@ -1180,7 +1047,6 @@ const SignerCounterpart = ({
} }
const Counterpart = ({ const Counterpart = ({
userMeta,
user, user,
handleUserRoleChange, handleUserRoleChange,
handleRemoveUser handleRemoveUser
@ -1188,15 +1054,7 @@ const Counterpart = ({
return ( return (
<> <>
<div className={styles.avatar}> <div className={styles.avatar}>
<UserAvatar <UserAvatar pubkey={user.pubkey} isNameVisible={true} />
pubkey={user.pubkey}
name={
userMeta?.display_name ||
userMeta?.name ||
shorten(hexToNpub(user.pubkey))
}
image={userMeta?.picture}
/>
</div> </div>
<Tooltip title="Toggle User Role" arrow disableInteractive> <Tooltip title="Toggle User Role" arrow disableInteractive>
<Button <Button

View File

@ -18,12 +18,16 @@ import {
} from '../../store/actions' } from '../../store/actions'
import { LoginMethods } from '../../store/auth/types' import { LoginMethods } from '../../store/auth/types'
import { Dispatch } from '../../store/store' import { Dispatch } from '../../store/store'
import { npubToHex, queryNip05 } from '../../utils' import { npubToHex, queryNip05, timeout } from '../../utils'
import { hexToBytes } from '@noble/hashes/utils' import { hexToBytes } from '@noble/hashes/utils'
import { NIP05_REGEX } from '../../constants' import { NIP05_REGEX } from '../../constants'
import styles from './styles.module.scss' import styles from './styles.module.scss'
import { TimeoutError } from '../../types/errors/TimeoutError'
const EXTENSION_LOGIN_DELAY_SECONDS = 5
const EXTENSION_LOGIN_TIMEOUT_SECONDS = EXTENSION_LOGIN_DELAY_SECONDS + 55
export const Nostr = () => { export const Nostr = () => {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
@ -31,11 +35,12 @@ export const Nostr = () => {
const navigate = useNavigate() const navigate = useNavigate()
const authController = new AuthController() const authController = new AuthController()
const metadataController = new MetadataController() const metadataController = MetadataController.getInstance()
const nostrController = NostrController.getInstance() const nostrController = NostrController.getInstance()
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [isExtensionSlow, setIsExtensionSlow] = useState(false)
const [inputValue, setInputValue] = useState('') const [inputValue, setInputValue] = useState('')
const [authUrl, setAuthUrl] = useState<string>() const [authUrl, setAuthUrl] = useState<string>()
@ -72,27 +77,43 @@ export const Nostr = () => {
} }
const loginWithExtension = async () => { const loginWithExtension = async () => {
let waitTimeout: number | undefined
try {
// Wait EXTENSION_LOGIN_DELAY_SECONDS before showing extension delay message
waitTimeout = window.setTimeout(() => {
setIsExtensionSlow(true)
}, EXTENSION_LOGIN_DELAY_SECONDS * 1000)
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Capturing pubkey from nostr extension') setLoadingSpinnerDesc('Capturing pubkey from nostr extension')
nostrController const pubkey = await nostrController.capturePublicKey()
.capturePublicKey()
.then(async (pubkey) => {
dispatch(updateLoginMethod(LoginMethods.extension)) dispatch(updateLoginMethod(LoginMethods.extension))
setLoadingSpinnerDesc('Authenticating and finding metadata') setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = const redirectPath = await Promise.race([
await authController.authAndGetMetadataAndRelaysMap(pubkey) authController.authAndGetMetadataAndRelaysMap(pubkey),
timeout(EXTENSION_LOGIN_TIMEOUT_SECONDS * 1000)
])
if (redirectPath) {
navigateAfterLogin(redirectPath)
}
} catch (error) {
if (error instanceof TimeoutError) {
// Just log the error, no toast, user has already been notified with the loading screen
console.error("Extension didn't respond in time")
} else {
toast.error('Error capturing public key from nostr extension: ' + error)
}
} finally {
// Clear the wait timeout so we don't change the state unnecessarily
window.clearTimeout(waitTimeout)
if (redirectPath) navigateAfterLogin(redirectPath)
})
.catch((err) => {
toast.error('Error capturing public key from nostr extension: ' + err)
})
.finally(() => {
setIsLoading(false) setIsLoading(false)
setLoadingSpinnerDesc('') setLoadingSpinnerDesc('')
}) setIsExtensionSlow(false)
}
} }
/** /**
@ -354,7 +375,33 @@ export const Nostr = () => {
return ( return (
<> <>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} {isLoading && (
<LoadingSpinner desc={loadingSpinnerDesc}>
{isExtensionSlow && (
<>
<p>
Your nostr extension is not responding. Check these
alternatives:{' '}
<a href="https://github.com/aljazceru/awesome-nostr?tab=readme-ov-file#nip-07-browser-extensions">
https://github.com/aljazceru/awesome-nostr
</a>
</p>
<br />
<Button
fullWidth
variant="contained"
onClick={() => {
setLoadingSpinnerDesc('')
setIsLoading(false)
setIsExtensionSlow(false)
}}
>
Close
</Button>
</>
)}
</LoadingSpinner>
)}
{isNostrExtensionAvailable && ( {isNostrExtensionAvailable && (
<> <>

View File

@ -27,7 +27,7 @@ export const ProfilePage = () => {
const { npub } = useParams() const { npub } = useParams()
const metadataController = useMemo(() => new MetadataController(), []) const metadataController = useMemo(() => MetadataController.getInstance(), [])
const [pubkey, setPubkey] = useState<string>() const [pubkey, setPubkey] = useState<string>()
const [nostrJoiningBlock, setNostrJoiningBlock] = const [nostrJoiningBlock, setNostrJoiningBlock] =

View File

@ -12,7 +12,7 @@ import {
useTheme useTheme
} from '@mui/material' } from '@mui/material'
import { UnsignedEvent, nip19, kinds, VerifiedEvent, Event } from 'nostr-tools' import { UnsignedEvent, nip19, kinds, VerifiedEvent, Event } from 'nostr-tools'
import React, { useEffect, useMemo, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { MetadataController, NostrController } from '../../../controllers' import { MetadataController, NostrController } from '../../../controllers'
@ -41,7 +41,7 @@ export const ProfileSettingsPage = () => {
const dispatch: Dispatch = useDispatch() const dispatch: Dispatch = useDispatch()
const metadataController = useMemo(() => new MetadataController(), []) const metadataController = MetadataController.getInstance()
const nostrController = NostrController.getInstance() const nostrController = NostrController.getInstance()
const [pubkey, setPubkey] = useState<string>() const [pubkey, setPubkey] = useState<string>()

View File

@ -33,7 +33,8 @@ import {
sendNotification, sendNotification,
signEventForMetaFile, signEventForMetaFile,
updateUsersAppData, updateUsersAppData,
findOtherUserMarks findOtherUserMarks,
timeout
} from '../../utils' } from '../../utils'
import { Container } from '../../components/Container' import { Container } from '../../components/Container'
import { DisplayMeta } from './internal/displayMeta' import { DisplayMeta } from './internal/displayMeta'
@ -276,17 +277,10 @@ export const SignPage = () => {
setAuthUrl(url) setAuthUrl(url)
}) })
// Set up timeout promise to handle encryption timeout // decrypt the encryptionKey, with timeout (duration = 60 seconds)
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error('Timeout occurred'))
}, 60000) // Timeout duration = 60 seconds
})
// decrypt the encryptionKey, with timeout
const encryptionKey = await Promise.race([ const encryptionKey = await Promise.race([
nostrController.nip04Decrypt(sender, key), nostrController.nip04Decrypt(sender, key),
timeoutPromise timeout(60000)
]) ])
.then((res) => { .then((res) => {
return res return res
@ -468,20 +462,20 @@ export const SignPage = () => {
const fileNames = Object.values(zip.files) const fileNames = Object.values(zip.files)
.filter((entry) => entry.name.startsWith('files/') && !entry.dir) .filter((entry) => entry.name.startsWith('files/') && !entry.dir)
.map((entry) => entry.name) .map((entry) => entry.name)
.map((entry) => entry.replace(/^files\//, ''))
// generate hashes for all entries in files folder of zipArchive for (const zipFilePath of fileNames) {
// these hashes can be used to verify the originality of files
for (const fileName of fileNames) {
const arrayBuffer = await readContentOfZipEntry( const arrayBuffer = await readContentOfZipEntry(
zip, zip,
fileName, zipFilePath,
'arraybuffer' 'arraybuffer'
) )
const fileName = zipFilePath.replace(/^files\//, '')
if (arrayBuffer) { if (arrayBuffer) {
files[fileName] = await convertToSigitFile(arrayBuffer, fileName) files[fileName] = await convertToSigitFile(arrayBuffer, fileName)
// generate hashes for all entries in files folder of zipArchive
// these hashes can be used to verify the originality of files
const hash = await getHash(arrayBuffer) const hash = await getHash(arrayBuffer)
if (hash) { if (hash) {
fileHashes[fileName] = hash fileHashes[fileName] = hash

View File

@ -32,7 +32,7 @@ import { useState, useEffect } from 'react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { UserAvatar } from '../../../components/UserAvatar' import { UserAvatar } from '../../../components/UserAvatar'
import { MetadataController } from '../../../controllers' import { MetadataController } from '../../../controllers'
import { npubToHex, shorten, hexToNpub, parseJson } from '../../../utils' import { npubToHex, hexToNpub, parseJson } from '../../../utils'
import styles from '../style.module.scss' import styles from '../style.module.scss'
import { SigitFile } from '../../../utils/file' import { SigitFile } from '../../../utils/file'
@ -105,7 +105,7 @@ export const DisplayMeta = ({
}, [signers, viewers]) }, [signers, viewers])
useEffect(() => { useEffect(() => {
const metadataController = new MetadataController() const metadataController = MetadataController.getInstance()
const hexKeys: string[] = [ const hexKeys: string[] = [
npubToHex(submittedBy)!, npubToHex(submittedBy)!,
@ -167,20 +167,7 @@ export const DisplayMeta = ({
<Typography variant="h6" sx={{ color: textColor }}> <Typography variant="h6" sx={{ color: textColor }}>
Submitted By Submitted By
</Typography> </Typography>
{(function () { <UserAvatar pubkey={submittedBy} isNameVisible={true} />
const profile = metadata[submittedBy]
return (
<UserAvatar
pubkey={submittedBy}
name={
profile?.display_name ||
profile?.name ||
shorten(hexToNpub(submittedBy))
}
image={profile?.picture}
/>
)
})()}
</ListItem> </ListItem>
<ListItem <ListItem
sx={{ sx={{
@ -280,14 +267,12 @@ type DisplayUserProps = {
const DisplayUser = ({ const DisplayUser = ({
meta, meta,
user, user,
metadata,
signedBy, signedBy,
nextSigner, nextSigner,
getPrevSignersSig getPrevSignersSig
}: DisplayUserProps) => { }: DisplayUserProps) => {
const theme = useTheme() const theme = useTheme()
const userMeta = metadata[user.pubkey]
const [userStatus, setUserStatus] = useState<UserStatus>(UserStatus.Pending) const [userStatus, setUserStatus] = useState<UserStatus>(UserStatus.Pending)
const [prevSignatureStatus, setPreviousSignatureStatus] = const [prevSignatureStatus, setPreviousSignatureStatus] =
useState<PrevSignatureValidationEnum>(PrevSignatureValidationEnum.Pending) useState<PrevSignatureValidationEnum>(PrevSignatureValidationEnum.Pending)
@ -370,15 +355,7 @@ const DisplayUser = ({
return ( return (
<TableRow> <TableRow>
<TableCell className={styles.tableCell}> <TableCell className={styles.tableCell}>
<UserAvatar <UserAvatar pubkey={user.pubkey} isNameVisible={true} />
pubkey={user.pubkey}
name={
userMeta?.display_name ||
userMeta?.name ||
shorten(hexToNpub(user.pubkey))
}
image={userMeta?.picture}
/>
</TableCell> </TableCell>
<TableCell className={styles.tableCell}>{user.role}</TableCell> <TableCell className={styles.tableCell}>{user.role}</TableCell>
<TableCell> <TableCell>

View File

@ -1,16 +1,11 @@
import { Box, Button, Tooltip, Typography } from '@mui/material' import { Box, Button, Typography } from '@mui/material'
import JSZip from 'jszip' import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input' import { MuiFileInput } from 'mui-file-input'
import { Event, verifyEvent } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { NostrController } from '../../controllers' import { NostrController } from '../../controllers'
import { import { DocSignatureEvent, Meta } from '../../types'
CreateSignatureEventContent,
DocSignatureEvent,
Meta
} from '../../types'
import { import {
decryptArrayBuffer, decryptArrayBuffer,
extractMarksFromSignedMeta, extractMarksFromSignedMeta,
@ -20,7 +15,6 @@ import {
parseJson, parseJson,
readContentOfZipEntry, readContentOfZipEntry,
signEventForMetaFile, signEventForMetaFile,
shorten,
getCurrentUserFiles getCurrentUserFiles
} from '../../utils' } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
@ -28,7 +22,6 @@ import { useLocation } from 'react-router-dom'
import axios from 'axios' import axios from 'axios'
import { import {
addMarks, addMarks,
convertToPdfBlob,
FONT_SIZE, FONT_SIZE,
FONT_TYPE, FONT_TYPE,
groupMarksByFileNamePage, groupMarksByFileNamePage,
@ -42,9 +35,6 @@ import { Container } from '../../components/Container'
import { useSigitMeta } from '../../hooks/useSigitMeta.tsx' import { useSigitMeta } from '../../hooks/useSigitMeta.tsx'
import { StickySideColumns } from '../../layouts/StickySideColumns.tsx' import { StickySideColumns } from '../../layouts/StickySideColumns.tsx'
import { UsersDetails } from '../../components/UsersDetails.tsx/index.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 FileList from '../../components/FileList'
import { CurrentUserFile } from '../../types/file.ts' import { CurrentUserFile } from '../../types/file.ts'
import { Mark } from '../../types/mark.ts' import { Mark } from '../../types/mark.ts'
@ -163,12 +153,26 @@ const SlimPdfView = ({
export const VerifyPage = () => { export const VerifyPage = () => {
const location = useLocation() const location = useLocation()
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const nostrController = NostrController.getInstance()
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
/** /**
* uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains meta.json * uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains meta.json
* meta will be received in navigation from create & home page in online mode * meta will be received in navigation from create & home page in online mode
*/ */
const { uploadedZip, meta } = location.state || {} const { uploadedZip, meta: metaInNavState } = location.state || {}
const [selectedFile, setSelectedFile] = useState<File | null>(null)
useEffect(() => {
if (uploadedZip) {
setSelectedFile(uploadedZip)
}
}, [uploadedZip])
const [meta, setMeta] = useState<Meta>(metaInNavState)
const { const {
submittedBy, submittedBy,
zipUrl, zipUrl,
@ -179,44 +183,22 @@ export const VerifyPage = () => {
parsedSignatureEvents parsedSignatureEvents
} = useSigitMeta(meta) } = useSigitMeta(meta)
const profiles = useSigitProfiles([ const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
...(submittedBy ? [submittedBy] : []),
...signers,
...viewers
])
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [currentFile, setCurrentFile] = useState<CurrentUserFile | null>(null)
const [currentFileHashes, setCurrentFileHashes] = useState<{ const [currentFileHashes, setCurrentFileHashes] = useState<{
[key: string]: string | null [key: string]: string | null
}>({}) }>({})
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
const [currentFile, setCurrentFile] = useState<CurrentUserFile | null>(null)
const [signatureFileHashes, setSignatureFileHashes] = useState<{
[key: string]: string
}>(fileHashes)
useEffect(() => {
setSignatureFileHashes(fileHashes)
}, [fileHashes])
useEffect(() => { useEffect(() => {
if (Object.entries(files).length > 0) { if (Object.entries(files).length > 0) {
const tmp = getCurrentUserFiles(files, fileHashes, signatureFileHashes) const tmp = getCurrentUserFiles(files, currentFileHashes, fileHashes)
setCurrentFile(tmp[0]) setCurrentFile(tmp[0])
} }
}, [signatureFileHashes, fileHashes, files]) }, [currentFileHashes, fileHashes, files])
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const nostrController = NostrController.getInstance()
useEffect(() => { useEffect(() => {
if (uploadedZip) { if (metaInNavState && encryptionKey) {
setSelectedFile(uploadedZip)
} else if (meta && encryptionKey) {
const processSigit = async () => { const processSigit = async () => {
setIsLoading(true) setIsLoading(true)
@ -301,7 +283,7 @@ export const VerifyPage = () => {
processSigit() processSigit()
} }
}, [encryptionKey, meta, uploadedZip, zipUrl]) }, [encryptionKey, metaInNavState, zipUrl])
const handleVerify = async () => { const handleVerify = async () => {
if (!selectedFile) return if (!selectedFile) return
@ -315,6 +297,7 @@ export const VerifyPage = () => {
if (!zip) return if (!zip) return
const files: { [filename: string]: SigitFile } = {}
const fileHashes: { [key: string]: string | null } = {} const fileHashes: { [key: string]: string | null } = {}
const fileNames = Object.values(zip.files) const fileNames = Object.values(zip.files)
.filter((entry) => entry.name.startsWith('files/') && !entry.dir) .filter((entry) => entry.name.startsWith('files/') && !entry.dir)
@ -322,24 +305,27 @@ export const VerifyPage = () => {
// generate hashes for all entries in files folder of zipArchive // generate hashes for all entries in files folder of zipArchive
// these hashes can be used to verify the originality of files // these hashes can be used to verify the originality of files
for (const fileName of fileNames) { for (const zipFilePath of fileNames) {
const arrayBuffer = await readContentOfZipEntry( const arrayBuffer = await readContentOfZipEntry(
zip, zip,
fileName, zipFilePath,
'arraybuffer' 'arraybuffer'
) )
const fileName = zipFilePath.replace(/^files\//, '')
if (arrayBuffer) { if (arrayBuffer) {
files[fileName] = await convertToSigitFile(arrayBuffer, fileName)
const hash = await getHash(arrayBuffer) const hash = await getHash(arrayBuffer)
if (hash) { if (hash) {
fileHashes[fileName.replace(/^files\//, '')] = hash fileHashes[fileName] = hash
} }
} else { } else {
fileHashes[fileName.replace(/^files\//, '')] = null fileHashes[fileName] = null
} }
} }
setFiles(files)
setCurrentFileHashes(fileHashes) setCurrentFileHashes(fileHashes)
setLoadingSpinnerDesc('Parsing meta.json') setLoadingSpinnerDesc('Parsing meta.json')
@ -368,43 +354,7 @@ export const VerifyPage = () => {
if (!parsedMetaJson) return if (!parsedMetaJson) return
const createSignatureEvent = await parseJson<Event>( setMeta(parsedMetaJson)
parsedMetaJson.createSignature
).catch((err) => {
console.log('err in parsing the createSignature event:>> ', err)
toast.error(
err.message || 'error occurred in parsing the create signature event'
)
setIsLoading(false)
return null
})
if (!createSignatureEvent) return
const isValidCreateSignature = verifyEvent(createSignatureEvent)
if (!isValidCreateSignature) {
toast.error('Create signature is invalid')
setIsLoading(false)
return
}
const createSignatureContent = await parseJson<CreateSignatureEventContent>(
createSignatureEvent.content
).catch((err) => {
console.log(
`err in parsing the createSignature event's content :>> `,
err
)
toast.error(
err.message ||
`error occurred in parsing the create signature event's content`
)
setIsLoading(false)
return null
})
if (!createSignatureContent) return
setIsLoading(false) setIsLoading(false)
} }
@ -448,8 +398,7 @@ export const VerifyPage = () => {
for (const [fileName, file] of Object.entries(files)) { for (const [fileName, file] of Object.entries(files)) {
if (file.isPdf) { if (file.isPdf) {
// Draw marks into PDF file and generate a brand new blob // Draw marks into PDF file and generate a brand new blob
const pages = await addMarks(file, marksByPage[fileName]) const blob = await addMarks(file, marksByPage[fileName])
const blob = await convertToPdfBlob(pages)
zip.file(`files/${fileName}`, blob) zip.file(`files/${fileName}`, blob)
} else { } else {
zip.file(`files/${fileName}`, file) zip.file(`files/${fileName}`, file)
@ -479,47 +428,6 @@ export const VerifyPage = () => {
setIsLoading(false) setIsLoading(false)
} }
const displayExportedBy = () => {
if (!meta || !meta.exportSignature) return null
const exportSignatureString = meta.exportSignature
try {
const exportSignatureEvent = JSON.parse(exportSignatureString) as Event
if (verifyEvent(exportSignatureEvent)) {
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 (
<Typography component="label" sx={{ color: 'red' }}>
Invalid export signature
</Typography>
)
}
} catch (error) {
console.error(`An error occurred wile parsing exportSignature`, error)
return null
}
}
return ( return (
<> <>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
@ -554,22 +462,19 @@ export const VerifyPage = () => {
{meta && ( {meta && (
<StickySideColumns <StickySideColumns
left={ left={
<> currentFile !== null && (
{currentFile !== null && (
<FileList <FileList
files={getCurrentUserFiles( files={getCurrentUserFiles(
files, files,
currentFileHashes, currentFileHashes,
signatureFileHashes fileHashes
)} )}
currentFile={currentFile} currentFile={currentFile}
setCurrentFile={setCurrentFile} setCurrentFile={setCurrentFile}
handleDownload={handleExport} handleDownload={handleExport}
downloadLabel="Download Sigit" downloadLabel="Download Sigit"
/> />
)} )
{displayExportedBy()}
</>
} }
right={<UsersDetails meta={meta} />} right={<UsersDetails meta={meta} />}
leftIcon={faFileDownload} leftIcon={faFileDownload}
@ -578,11 +483,7 @@ export const VerifyPage = () => {
> >
<SlimPdfView <SlimPdfView
currentFile={currentFile} currentFile={currentFile}
files={getCurrentUserFiles( files={getCurrentUserFiles(files, currentFileHashes, fileHashes)}
files,
currentFileHashes,
signatureFileHashes
)}
parsedSignatureEvents={parsedSignatureEvents} parsedSignatureEvents={parsedSignatureEvents}
/> />
</StickySideColumns> </StickySideColumns>

View File

@ -61,6 +61,6 @@
[data-dev='true'] { [data-dev='true'] {
.mark { .mark {
border: 1px dotted black; outline: 1px dotted black;
} }
} }

View File

@ -0,0 +1,6 @@
export class TimeoutError extends Error {
constructor() {
super('Timeout')
this.name = this.constructor.name
}
}

View File

@ -21,6 +21,8 @@ export const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000
export const SIGIT_RELAY = 'wss://relay.sigit.io' export const SIGIT_RELAY = 'wss://relay.sigit.io'
export const SIGIT_BLOSSOM = 'https://blossom.sigit.io'
export const DEFAULT_LOOK_UP_RELAY_LIST = [ export const DEFAULT_LOOK_UP_RELAY_LIST = [
SIGIT_RELAY, SIGIT_RELAY,
'wss://user.kindpag.es', 'wss://user.kindpag.es',

View File

@ -13,7 +13,7 @@ import { setRelayInfoAction } from '../store/actions'
export const getNostrJoiningBlockNumber = async ( export const getNostrJoiningBlockNumber = async (
hexKey: string hexKey: string
): Promise<NostrJoiningBlock | null> => { ): Promise<NostrJoiningBlock | null> => {
const metadataController = new MetadataController() const metadataController = MetadataController.getInstance()
const relaySet = await metadataController.findRelayListMetadata(hexKey) const relaySet = await metadataController.findRelayListMetadata(hexKey)

View File

@ -4,7 +4,6 @@ import { MOST_COMMON_MEDIA_TYPES } from './const.ts'
import { extractMarksFromSignedMeta } from './mark.ts' import { extractMarksFromSignedMeta } from './mark.ts'
import { import {
addMarks, addMarks,
convertToPdfBlob,
groupMarksByFileNamePage, groupMarksByFileNamePage,
isPdf, isPdf,
pdfToImages pdfToImages
@ -22,8 +21,7 @@ export const getZipWithFiles = async (
for (const [fileName, file] of Object.entries(files)) { for (const [fileName, file] of Object.entries(files)) {
if (file.isPdf) { if (file.isPdf) {
// Handle PDF Files // Handle PDF Files
const pages = await addMarks(file, marksByFileNamePage[fileName]) const blob = await addMarks(file, marksByFileNamePage[fileName])
const blob = await convertToPdfBlob(pages)
zip.file(`files/${fileName}`, blob) zip.file(`files/${fileName}`, blob)
} else { } else {
// Handle other files // Handle other files

View File

@ -3,6 +3,27 @@ import { hexToNpub } from './nostr.ts'
import { Meta, SignedEventContent } from '../types' import { Meta, SignedEventContent } from '../types'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { EMPTY } from './const.ts' import { EMPTY } from './const.ts'
import { MarkType } from '../types/drawing.ts'
import {
faT,
faSignature,
faBriefcase,
faIdCard,
faHeading,
faClock,
faCalendarDays,
fa1,
faImage,
faSquareCheck,
faCheckDouble,
faPaperclip,
faCircleDot,
faSquareCaretDown,
faTableCellsLarge,
faStamp,
faCreditCard,
faPhone
} from '@fortawesome/free-solid-svg-icons'
/** /**
* Takes in an array of Marks already filtered by User. * Takes in an array of Marks already filtered by User.
@ -131,6 +152,121 @@ const findOtherUserMarks = (marks: Mark[], pubkey: string): Mark[] => {
return marks.filter((mark) => mark.npub !== hexToNpub(pubkey)) return marks.filter((mark) => mark.npub !== hexToNpub(pubkey))
} }
export const DEFAULT_TOOLBOX = [
{
identifier: MarkType.TEXT,
icon: faT,
label: 'Text',
active: true
},
{
identifier: MarkType.SIGNATURE,
icon: faSignature,
label: 'Signature',
active: false
},
{
identifier: MarkType.JOBTITLE,
icon: faBriefcase,
label: 'Job Title',
active: false
},
{
identifier: MarkType.FULLNAME,
icon: faIdCard,
label: 'Full Name',
active: false
},
{
identifier: MarkType.INITIALS,
icon: faHeading,
label: 'Initials',
active: false
},
{
identifier: MarkType.DATETIME,
icon: faClock,
label: 'Date Time',
active: false
},
{
identifier: MarkType.DATE,
icon: faCalendarDays,
label: 'Date',
active: false
},
{
identifier: MarkType.NUMBER,
icon: fa1,
label: 'Number',
active: false
},
{
identifier: MarkType.IMAGES,
icon: faImage,
label: 'Images',
active: false
},
{
identifier: MarkType.CHECKBOX,
icon: faSquareCheck,
label: 'Checkbox',
active: false
},
{
identifier: MarkType.MULTIPLE,
icon: faCheckDouble,
label: 'Multiple',
active: false
},
{
identifier: MarkType.FILE,
icon: faPaperclip,
label: 'File',
active: false
},
{
identifier: MarkType.RADIO,
icon: faCircleDot,
label: 'Radio',
active: false
},
{
identifier: MarkType.SELECT,
icon: faSquareCaretDown,
label: 'Select',
active: false
},
{
identifier: MarkType.CELLS,
icon: faTableCellsLarge,
label: 'Cells',
active: false
},
{
identifier: MarkType.STAMP,
icon: faStamp,
label: 'Stamp',
active: false
},
{
identifier: MarkType.PAYMENT,
icon: faCreditCard,
label: 'Payment',
active: false
},
{
identifier: MarkType.PHONE,
icon: faPhone,
label: 'Phone',
active: false
}
]
export const getToolboxLabelByMarkType = (markType: MarkType) => {
return DEFAULT_TOOLBOX.find((t) => t.identifier === markType)?.label
}
export { export {
getCurrentUserMarks, getCurrentUserMarks,
filterMarksByPubkey, filterMarksByPubkey,

View File

@ -16,6 +16,8 @@ import { CreateSignatureEventContent, Meta } from '../types'
import { hexToNpub, unixNow } from './nostr' import { hexToNpub, unixNow } from './nostr'
import { parseJson } from './string' import { parseJson } from './string'
import { hexToBytes } from '@noble/hashes/utils' import { hexToBytes } from '@noble/hashes/utils'
import { getHash } from './hash.ts'
import { SIGIT_BLOSSOM } from './const.ts'
/** /**
* Uploads a file to a file storage service. * Uploads a file to a file storage service.
@ -25,12 +27,18 @@ import { hexToBytes } from '@noble/hashes/utils'
*/ */
export const uploadToFileStorage = async (file: File) => { export const uploadToFileStorage = async (file: File) => {
// Define event metadata for authorization // Define event metadata for authorization
const hash = await getHash(await file.arrayBuffer())
if (!hash) {
throw new Error("Can't get file hash.")
}
const event: EventTemplate = { const event: EventTemplate = {
kind: 24242, kind: 24242,
content: 'Authorize Upload', content: 'Authorize Upload',
created_at: unixNow(), created_at: unixNow(),
tags: [ tags: [
['t', 'upload'], ['t', 'upload'],
['x', hash],
['expiration', String(unixNow() + 60 * 5)], // Set expiration time to 5 minutes from now ['expiration', String(unixNow() + 60 * 5)], // Set expiration time to 5 minutes from now
['name', file.name], ['name', file.name],
['size', String(file.size)] ['size', String(file.size)]
@ -47,11 +55,8 @@ export const uploadToFileStorage = async (file: File) => {
// Sign the authorization event using the dedicated key stored in user app data // Sign the authorization event using the dedicated key stored in user app data
const authEvent = finalizeEvent(event, hexToBytes(key)) const authEvent = finalizeEvent(event, hexToBytes(key))
// URL of the file storage service
const FILE_STORAGE_URL = 'https://blossom.sigit.io' // REFACTOR: should be an env
// Upload the file to the file storage service using Axios // Upload the file to the file storage service using Axios
const response = await axios.put(`${FILE_STORAGE_URL}/upload`, file, { const response = await axios.put(`${SIGIT_BLOSSOM}/upload`, file, {
headers: { headers: {
Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)), // Set authorization header Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)), // Set authorization header
'Content-Type': 'application/sigit' // Set content type header 'Content-Type': 'application/sigit' // Set content type header

View File

@ -35,6 +35,7 @@ import { getDefaultRelayMap } from './relays'
import { parseJson, removeLeadingSlash } from './string' import { parseJson, removeLeadingSlash } from './string'
import { timeout } from './utils' import { timeout } from './utils'
import { getHash } from './hash' import { getHash } from './hash'
import { SIGIT_BLOSSOM } from './const.ts'
/** /**
* Generates a `d` tag for userAppData * Generates a `d` tag for userAppData
@ -365,7 +366,7 @@ export const getUsersAppData = async (): Promise<UserAppData | null> => {
// Check if relayMap is undefined in the Redux store // Check if relayMap is undefined in the Redux store
if (!relayMap) { if (!relayMap) {
// If relayMap is not present, fetch relay list metadata // If relayMap is not present, fetch relay list metadata
const metadataController = new MetadataController() const metadataController = MetadataController.getInstance()
const relaySet = await metadataController const relaySet = await metadataController
.findRelayListMetadata(usersPubkey) .findRelayListMetadata(usersPubkey)
.catch((err) => { .catch((err) => {
@ -723,6 +724,11 @@ const uploadUserAppDataToBlossom = async (
type: 'application/octet-stream' type: 'application/octet-stream'
}) })
const hash = await getHash(await file.arrayBuffer())
if (!hash) {
throw new Error("Can't get file hash.")
}
// Define event metadata for authorization // Define event metadata for authorization
const event: EventTemplate = { const event: EventTemplate = {
kind: 24242, kind: 24242,
@ -730,6 +736,7 @@ const uploadUserAppDataToBlossom = async (
created_at: unixNow(), created_at: unixNow(),
tags: [ tags: [
['t', 'upload'], ['t', 'upload'],
['x', hash],
['expiration', String(unixNow() + 60 * 5)], // Set expiration time to 5 minutes from now ['expiration', String(unixNow() + 60 * 5)], // Set expiration time to 5 minutes from now
['name', file.name], ['name', file.name],
['size', String(file.size)] ['size', String(file.size)]
@ -739,11 +746,8 @@ const uploadUserAppDataToBlossom = async (
// Finalize the event with the private key // Finalize the event with the private key
const authEvent = finalizeEvent(event, hexToBytes(privateKey)) const authEvent = finalizeEvent(event, hexToBytes(privateKey))
// URL of the file storage service
const FILE_STORAGE_URL = 'https://blossom.sigit.io'
// Upload the file to the file storage service using Axios // Upload the file to the file storage service using Axios
const response = await axios.put(`${FILE_STORAGE_URL}/upload`, file, { const response = await axios.put(`${SIGIT_BLOSSOM}/upload`, file, {
headers: { headers: {
Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)) // Set authorization header Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)) // Set authorization header
} }
@ -835,7 +839,7 @@ const getUserAppDataFromBlossom = async (url: string, privateKey: string) => {
*/ */
export const subscribeForSigits = async (pubkey: string) => { export const subscribeForSigits = async (pubkey: string) => {
// Instantiate the MetadataController to retrieve relay list metadata // Instantiate the MetadataController to retrieve relay list metadata
const metadataController = new MetadataController() const metadataController = MetadataController.getInstance()
const relaySet = await metadataController const relaySet = await metadataController
.findRelayListMetadata(pubkey) .findRelayListMetadata(pubkey)
.catch((err) => { .catch((err) => {
@ -939,7 +943,7 @@ export const sendNotification = async (receiver: string, meta: Meta) => {
const wrappedEvent = createWrap(unsignedEvent, receiver) const wrappedEvent = createWrap(unsignedEvent, receiver)
// Instantiate the MetadataController to retrieve relay list metadata // Instantiate the MetadataController to retrieve relay list metadata
const metadataController = new MetadataController() const metadataController = MetadataController.getInstance()
const relaySet = await metadataController const relaySet = await metadataController
.findRelayListMetadata(receiver) .findRelayListMetadata(receiver)
.catch((err) => { .catch((err) => {

View File

@ -1,7 +1,6 @@
import { PdfPage } from '../types/drawing.ts' import { PdfPage } from '../types/drawing.ts'
import { PDFDocument } from 'pdf-lib' import { PDFDocument, PDFFont, PDFPage, rgb } from 'pdf-lib'
import { Mark } from '../types/mark.ts' import { Mark } from '../types/mark.ts'
import * as PDFJS from 'pdfjs-dist' import * as PDFJS from 'pdfjs-dist'
import PDFJSWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?worker' import PDFJSWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?worker'
if (!PDFJS.GlobalWorkerOptions.workerPort) { if (!PDFJS.GlobalWorkerOptions.workerPort) {
@ -10,18 +9,23 @@ if (!PDFJS.GlobalWorkerOptions.workerPort) {
PDFJS.GlobalWorkerOptions.workerPort = worker PDFJS.GlobalWorkerOptions.workerPort = worker
} }
import fontkit from '@pdf-lib/fontkit'
import defaultFont from '../assets/fonts/roboto-regular.ttf'
/** /**
* Defined font size used when generating a PDF. Currently it is difficult to fully * Defined font size used when generating a PDF. Currently it is difficult to fully
* correlate font size used at the time of filling in / drawing on the PDF * correlate font size used at the time of filling in / drawing on the PDF
* because it is dynamically rendered, and the final size. * because it is dynamically rendered, and the final size.
* This should be fixed going forward.
* Switching to PDF-Lib will most likely make this problem redundant.
*/ */
export const FONT_SIZE: number = 16 export const FONT_SIZE: number = 16
/** /**
* Current font type used when generating a PDF. * Current font type used when generating a PDF.
*/ */
export const FONT_TYPE: string = 'Arial' export const FONT_TYPE: string = 'Roboto'
/**
* Current line height used when generating a PDF.
*/
export const FONT_LINE_HEIGHT: number = 1
/** /**
* A utility that transforms a drawing coordinate number into a CSS-compatible pixel string * A utility that transforms a drawing coordinate number into a CSS-compatible pixel string
@ -115,56 +119,28 @@ export const pdfToImages = async (
/** /**
* Takes in individual pdf file and an object with Marks grouped by Page number * Takes in individual pdf file and an object with Marks grouped by Page number
* Returns an array of encoded images where each image is a representation * Returns a PDF blob with embedded, completed and signed marks from all users as text
* of a PDF page with completed and signed marks from all users
*/ */
export const addMarks = async ( export const addMarks = async (
file: File, file: File,
marksPerPage: { [key: string]: Mark[] } marksPerPage: { [key: string]: Mark[] }
) => { ) => {
const p = await readPdf(file) const p = await readPdf(file)
const pdf = await PDFJS.getDocument(p).promise const pdf = await PDFDocument.load(p)
const canvas = document.createElement('canvas') const robotoFont = await embedFont(pdf)
const pages = pdf.getPages()
const images: string[] = []
for (let i = 0; i < pdf.numPages; i++) {
const page = await pdf.getPage(i + 1)
const viewport = page.getViewport({ scale: 1 })
const context = canvas.getContext('2d')
canvas.height = viewport.height
canvas.width = viewport.width
if (context) {
await page.render({ canvasContext: context, viewport: viewport }).promise
for (let i = 0; i < pages.length; i++) {
if (marksPerPage && Object.hasOwn(marksPerPage, i)) { if (marksPerPage && Object.hasOwn(marksPerPage, i)) {
marksPerPage[i]?.forEach((mark) => draw(mark, context)) marksPerPage[i]?.forEach((mark) =>
} drawMarkText(mark, pages[i], robotoFont)
)
images.push(canvas.toDataURL())
} }
} }
canvas.remove() const blob = await pdf.save()
return images return blob
}
/**
* Utility to scale mark in line with the PDF-to-PNG scale
*/
export const scaleMark = (mark: Mark, scale: number): Mark => {
const { location } = mark
return {
...mark,
location: {
...location,
width: location.width * scale,
height: location.height * scale,
left: location.left * scale,
top: location.top * scale
}
}
} }
/** /**
@ -177,6 +153,7 @@ export const hasValue = (mark: Mark): boolean => !!mark.value
* Draws a Mark on a Canvas representation of a PDF Page * Draws a Mark on a Canvas representation of a PDF Page
* @param mark to be drawn * @param mark to be drawn
* @param ctx a Canvas representation of a specific PDF Page * @param ctx a Canvas representation of a specific PDF Page
* @deprecated use drawMarkText
*/ */
export const draw = (mark: Mark, ctx: CanvasRenderingContext2D) => { export const draw = (mark: Mark, ctx: CanvasRenderingContext2D) => {
const { location } = mark const { location } = mark
@ -191,29 +168,39 @@ export const draw = (mark: Mark, ctx: CanvasRenderingContext2D) => {
} }
/** /**
* Takes an array of encoded PDF pages and returns a blob that is a complete PDF file * Draws a Mark on a PDF Page
* @param markedPdfPages * @param mark to be drawn
* @param page PDF Page
* @param font embedded font
*/ */
export const convertToPdfBlob = async ( export const drawMarkText = (mark: Mark, page: PDFPage, font: PDFFont) => {
markedPdfPages: string[] const { location } = mark
): Promise<Blob> => { const { height } = page.getSize()
const pdfDoc = await PDFDocument.create()
for (const page of markedPdfPages) { // Convert the mark location origin (top, left) to PDF origin (bottom, left)
const pngImage = await pdfDoc.embedPng(page) const x = location.left
const p = pdfDoc.addPage([pngImage.width, pngImage.height]) const y = height - location.top
p.drawImage(pngImage, {
x: 0, // Adjust y-coordinate for the text, drawText's y is the baseline for the font
y: 0, // We start from the y (top location border) and we need to bump it down
width: pngImage.width, // We move font baseline by the difference between rendered height and actual height (gap)
height: pngImage.height // And finally move down by the height without descender to get the new baseline
const adjustedY =
y -
(font.heightAtSize(FONT_SIZE) - FONT_SIZE) -
font.heightAtSize(FONT_SIZE, { descender: false })
page.drawText(`${mark.value}`, {
x,
y: adjustedY,
size: FONT_SIZE,
font: font,
color: rgb(0, 0, 0),
maxWidth: location.width,
lineHeight: FONT_SIZE * FONT_LINE_HEIGHT
}) })
} }
const pdfBytes = await pdfDoc.save()
return new Blob([pdfBytes], { type: 'application/pdf' })
}
/** /**
* @param marks - an array of Marks * @param marks - an array of Marks
* @function hasValue removes any Mark without a property * @function hasValue removes any Mark without a property
@ -249,3 +236,12 @@ export const byPage = (
} }
} }
} }
async function embedFont(pdf: PDFDocument) {
const fontBytes = await fetch(defaultFont).then((res) => res.arrayBuffer())
pdf.registerFontkit(fontkit)
const embeddedFont = await pdf.embedFont(fontBytes)
return embeddedFont
}

View File

@ -1,3 +1,4 @@
import { TimeoutError } from '../types/errors/TimeoutError.ts'
import { CurrentUserFile } from '../types/file.ts' import { CurrentUserFile } from '../types/file.ts'
import { SigitFile } from './file.ts' import { SigitFile } from './file.ts'
@ -34,7 +35,7 @@ export const isOnline = async () => {
try { try {
// Define a URL to check the online status // Define a URL to check the online status
const url = 'https://www.google.com' const url = document.location.pathname + '?v=' + new Date().getTime()
// Make a HEAD request to the URL with 'no-cors' mode // Make a HEAD request to the URL with 'no-cors' mode
// This mode is used to handle opaque responses which do not expose their content // This mode is used to handle opaque responses which do not expose their content
@ -63,7 +64,7 @@ export const timeout = (ms: number = 60000) => {
// Set a timeout using setTimeout // Set a timeout using setTimeout
setTimeout(() => { setTimeout(() => {
// Reject the promise with an Error indicating a timeout // Reject the promise with an Error indicating a timeout
reject(new Error('Timeout')) reject(new TimeoutError())
}, ms) // Timeout duration in milliseconds }, ms) // Timeout duration in milliseconds
}) })
} }