diff --git a/package-lock.json b/package-lock.json index 2bcd952..71e0923 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@mui/material": "5.15.11", "@noble/hashes": "^1.4.0", "@nostr-dev-kit/ndk": "2.5.0", + "@pdf-lib/fontkit": "^1.1.1", "@reduxjs/toolkit": "2.2.1", "axios": "^1.7.4", "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", diff --git a/package.json b/package.json index aaeba6e..a833103 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@mui/material": "5.15.11", "@noble/hashes": "^1.4.0", "@nostr-dev-kit/ndk": "2.5.0", + "@pdf-lib/fontkit": "^1.1.1", "@reduxjs/toolkit": "2.2.1", "axios": "^1.7.4", "crypto-hash": "3.0.0", diff --git a/src/App.scss b/src/App.scss index 4d95be6..c4fa323 100644 --- a/src/App.scss +++ b/src/App.scss @@ -135,12 +135,18 @@ li { // Consistent styling for every file mark // Reverts some of the design defaults for font .file-mark { - font-family: Arial; - font-size: 16px; + font-family: 'Roboto'; + font-style: normal; font-weight: normal; - color: black; 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; } diff --git a/src/assets/fonts/roboto-regular.ttf b/src/assets/fonts/roboto-regular.ttf new file mode 100644 index 0000000..2d116d9 Binary files /dev/null and b/src/assets/fonts/roboto-regular.ttf differ diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx index 8c7ac63..aaabbd3 100644 --- a/src/components/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -37,7 +37,7 @@ import { setUserRobotImage } from '../../store/userRobotImage/action' import { Container } from '../Container' import { ButtonIcon } from '../ButtonIcon' -const metadataController = new MetadataController() +const metadataController = MetadataController.getInstance() export const AppBar = () => { const navigate = useNavigate() diff --git a/src/components/DisplaySigit/index.tsx b/src/components/DisplaySigit/index.tsx index 473a942..62f397c 100644 --- a/src/components/DisplaySigit/index.tsx +++ b/src/components/DisplaySigit/index.tsx @@ -1,7 +1,7 @@ import { Meta } from '../../types' import { SigitCardDisplayInfo, SigitStatus, SignStatus } from '../../utils' import { Link } from 'react-router-dom' -import { formatTimestamp, hexToNpub, npubToHex, shorten } from '../../utils' +import { formatTimestamp, npubToHex } from '../../utils' import { appPublicRoutes, appPrivateRoutes } from '../../routes' import { Button, Divider, Tooltip } from '@mui/material' import { DisplaySigner } from '../DisplaySigner' @@ -17,9 +17,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { UserAvatarGroup } from '../UserAvatarGroup' import styles from './style.module.scss' -import { TooltipChild } from '../TooltipChild' import { getExtensionIconLabel } from '../getExtensionIconLabel' -import { useSigitProfiles } from '../../hooks/useSigitProfiles' import { useSigitMeta } from '../../hooks/useSigitMeta' import { extractFileExtensions } from '../../utils/file' @@ -33,12 +31,6 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => { parsedMeta const { signersStatus, fileHashes } = useSigitMeta(meta) - - const profiles = useSigitProfiles([ - ...(submittedBy ? [submittedBy] : []), - ...signers - ]) - const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes)) return ( @@ -54,62 +46,29 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => { >

{title}

- {submittedBy && - (function () { - const profile = profiles[submittedBy] - return ( - - - - - - ) - })()} + {submittedBy && ( + + )} {submittedBy && signers.length ? ( ) : null} {signers.map((signer) => { const pubkey = npubToHex(signer)! - const profile = profiles[pubkey] - return ( - - - - - + ) })}
-
+
{createdAt ? formatTimestamp(createdAt) : null}
diff --git a/src/components/DisplaySigner/index.tsx b/src/components/DisplaySigner/index.tsx index 63aa154..e05dcae 100644 --- a/src/components/DisplaySigner/index.tsx +++ b/src/components/DisplaySigner/index.tsx @@ -1,5 +1,4 @@ import { Badge } from '@mui/material' -import { ProfileMetadata } from '../../types' import styles from './style.module.scss' import { UserAvatar } from '../UserAvatar' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' @@ -15,38 +14,33 @@ import { SignStatus } from '../../utils' import { Spinner } from '../Spinner' type DisplaySignerProps = { - profile: ProfileMetadata pubkey: string status: SignStatus } -export const DisplaySigner = ({ - status, - profile, - pubkey -}: DisplaySignerProps) => { - const getStatusIcon = (status: SignStatus) => { - switch (status) { - case SignStatus.Signed: - return - case SignStatus.Awaiting: - return ( - - - - ) - case SignStatus.Pending: - return - case SignStatus.Invalid: - return - case SignStatus.Viewer: - return +const getStatusIcon = (status: SignStatus) => { + switch (status) { + case SignStatus.Signed: + return + case SignStatus.Awaiting: + return ( + + + + ) + case SignStatus.Pending: + return + case SignStatus.Invalid: + return + case SignStatus.Viewer: + return - default: - return - } + default: + return } +} +export const DisplaySigner = ({ status, pubkey }: DisplaySignerProps) => { return ( {getStatusIcon(status)}
} > - + ) } diff --git a/src/components/DrawPDFFields/index.tsx b/src/components/DrawPDFFields/index.tsx index 7efae8b..7c1227d 100644 --- a/src/components/DrawPDFFields/index.tsx +++ b/src/components/DrawPDFFields/index.tsx @@ -14,9 +14,10 @@ import { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing' import { truncate } from 'lodash' import { settleAllFullfilfedPromises, hexToNpub, npubToHex } from '../../utils' import { getSigitFile, SigitFile } from '../../utils/file' +import { getToolboxLabelByMarkType } from '../../utils/mark' import { FileDivider } from '../FileDivider' import { ExtensionFileBox } from '../ExtensionFileBox' -import { inPx } from '../../utils/pdf' +import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf' import { useScale } from '../../hooks/useScale' import { AvatarIconButton } from '../UserAvatarIconButton' import { LoadingSpinner } from '../LoadingSpinner' @@ -390,7 +391,7 @@ export const DrawPDFFields = (props: Props) => { backgroundColor: drawnField.counterpart ? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}4b` : undefined, - borderColor: drawnField.counterpart + outlineColor: drawnField.counterpart ? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}` : undefined, left: inPx(from(page.width, drawnField.left)), @@ -406,6 +407,16 @@ export const DrawPDFFields = (props: Props) => { : undefined }} > +
+ {getToolboxLabelByMarkType(drawnField.type) || + 'placeholder'} +
handleResizePointerDown(event, drawnFieldIndex) diff --git a/src/components/DrawPDFFields/style.module.scss b/src/components/DrawPDFFields/style.module.scss index 62fa688..13afb9f 100644 --- a/src/components/DrawPDFFields/style.module.scss +++ b/src/components/DrawPDFFields/style.module.scss @@ -13,9 +13,15 @@ } } +.placeholder { + position: absolute; + opacity: 0.5; + inset: 0; +} + .drawingRectangle { position: absolute; - border: 1px solid #01aaad; + outline: 1px solid #01aaad; z-index: 50; background-color: #01aaad4b; cursor: pointer; @@ -29,7 +35,7 @@ } &.edited { - border: 1px dotted #01aaad; + outline: 1px dotted #01aaad; } .resizeHandle { diff --git a/src/components/LoadingSpinner/index.tsx b/src/components/LoadingSpinner/index.tsx index 2c6f4e5..d19d01f 100644 --- a/src/components/LoadingSpinner/index.tsx +++ b/src/components/LoadingSpinner/index.tsx @@ -1,12 +1,14 @@ +import { createPortal } from 'react-dom' import styles from './style.module.scss' +import { PropsWithChildren } from 'react' interface Props { desc?: string variant?: 'small' | 'default' } -export const LoadingSpinner = (props: Props) => { - const { desc, variant = 'default' } = props +export const LoadingSpinner = (props: PropsWithChildren) => { + const { desc, children, variant = 'default' } = props switch (variant) { case 'small': @@ -20,16 +22,22 @@ export const LoadingSpinner = (props: Props) => { ) default: - return ( + return createPortal(
- {desc &&

{desc}

} + {desc && ( +
+ {desc} + {children} +
+ )}
-
+ , + document.getElementById('root')! ) } } diff --git a/src/components/LoadingSpinner/style.module.scss b/src/components/LoadingSpinner/style.module.scss index e1a5978..d51b743 100644 --- a/src/components/LoadingSpinner/style.module.scss +++ b/src/components/LoadingSpinner/style.module.scss @@ -42,11 +42,15 @@ width: 100%; padding: 15px; border-top: solid 1px rgba(0, 0, 0, 0.1); - text-align: center; color: rgba(0, 0, 0, 0.5); font-size: 16px; font-weight: 400; + + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; } @keyframes spin { diff --git a/src/components/PDFView/PdfMarkItem.tsx b/src/components/PDFView/PdfMarkItem.tsx index db57800..cb06455 100644 --- a/src/components/PDFView/PdfMarkItem.tsx +++ b/src/components/PDFView/PdfMarkItem.tsx @@ -36,7 +36,7 @@ const PdfMarkItem = forwardRef( backgroundColor: selectedMark?.mark.npub ? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}4b` : undefined, - borderColor: selectedMark?.mark.npub + outlineColor: selectedMark?.mark.npub ? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}` : undefined, left: inPx(from(pageWidth, location.left)), diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx index 6049a07..0ea1fc1 100644 --- a/src/components/UserAvatar/index.tsx +++ b/src/components/UserAvatar/index.tsx @@ -3,34 +3,56 @@ import { getProfileRoute } from '../../routes' import styles from './styles.module.scss' import { AvatarIconButton } from '../UserAvatarIconButton' 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 { - name?: string pubkey: string - image?: string + isNameVisible?: boolean } /** * This component will be used for the displaying username and profile picture. * 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 ( - - {name ? {name} : null} + + + + + + {isNameVisible && name ? ( + {name} + ) : null} ) } diff --git a/src/components/UsersDetails.tsx/index.tsx b/src/components/UsersDetails.tsx/index.tsx index bddae82..3a5c358 100644 --- a/src/components/UsersDetails.tsx/index.tsx +++ b/src/components/UsersDetails.tsx/index.tsx @@ -1,11 +1,9 @@ import { Divider, Tooltip } from '@mui/material' -import { useSigitProfiles } from '../../hooks/useSigitProfiles' import { formatTimestamp, fromUnixTimestamp, hexToNpub, npubToHex, - shorten, SignStatus } from '../../utils' import { useSigitMeta } from '../../hooks/useSigitMeta' @@ -24,10 +22,10 @@ import { import { getExtensionIconLabel } from '../getExtensionIconLabel' import { useSelector } from 'react-redux' import { State } from '../../store/rootReducer' -import { TooltipChild } from '../TooltipChild' import { DisplaySigner } from '../DisplaySigner' import { Meta } from '../../types' import { extractFileExtensions } from '../../utils/file' +import { UserAvatar } from '../UserAvatar' interface UsersDetailsProps { meta: Meta @@ -36,6 +34,7 @@ interface UsersDetailsProps { export const UsersDetails = ({ meta }: UsersDetailsProps) => { const { submittedBy, + exportedBy, signers, viewers, fileHashes, @@ -47,11 +46,6 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => { isValid } = useSigitMeta(meta) const { usersPubkey } = useSelector((state: State) => state.auth) - const profiles = useSigitProfiles([ - ...(submittedBy ? [submittedBy] : []), - ...signers, - ...viewers - ]) const userCanSign = typeof usersPubkey !== 'undefined' && signers.includes(hexToNpub(usersPubkey)) @@ -63,31 +57,12 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {

Signers

- {submittedBy && - (function () { - const profile = profiles[submittedBy] - return ( - - - - - - ) - })()} + {submittedBy && ( + + )} {submittedBy && signers.length ? ( @@ -96,26 +71,12 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => { {signers.map((signer) => { const pubkey = npubToHex(signer)! - const profile = profiles[pubkey] - return ( - - - - - + ) })} @@ -128,34 +89,28 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => { {viewers.map((signer) => { const pubkey = npubToHex(signer)! - const profile = profiles[pubkey] return ( - - - - - + ) })}
)} + + {exportedBy && ( + <> +

Exported By

+
+ +
+ + )}

Details

diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts index 09b20df..cc8def5 100644 --- a/src/controllers/AuthController.ts +++ b/src/controllers/AuthController.ts @@ -25,7 +25,7 @@ export class AuthController { constructor() { this.nostrController = NostrController.getInstance() - this.metadataController = new MetadataController() + this.metadataController = MetadataController.getInstance() } /** diff --git a/src/controllers/MetadataController.ts b/src/controllers/MetadataController.ts index 8053874..984afd3 100644 --- a/src/controllers/MetadataController.ts +++ b/src/controllers/MetadataController.ts @@ -22,6 +22,7 @@ import { import { DEFAULT_LOOK_UP_RELAY_LIST } from '../utils/const' export class MetadataController extends EventEmitter { + private static instance: MetadataController private nostrController: NostrController private specialMetadataRelay = 'wss://purplepag.es' private pendingFetches = new Map>() // Track pending fetches @@ -31,6 +32,13 @@ export class MetadataController extends EventEmitter { 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. * 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 if (isOlderThanOneDay(cachedMetadataEvent.cachedAt)) { // If older than one week, find the metadata from relays in background - this.checkForMoreRecentMetadata(hexKey, cachedMetadataEvent.event) } diff --git a/src/hooks/useProfileMetadata.tsx b/src/hooks/useProfileMetadata.tsx new file mode 100644 index 0000000..f746f0d --- /dev/null +++ b/src/hooks/useProfileMetadata.tsx @@ -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() + + 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 +} diff --git a/src/hooks/useSigitMeta.tsx b/src/hooks/useSigitMeta.tsx index 088940e..22a6a6d 100644 --- a/src/hooks/useSigitMeta.tsx +++ b/src/hooks/useSigitMeta.tsx @@ -33,6 +33,10 @@ export interface FlatMeta // Remove pubkey and use submittedBy as `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 createdAt?: number @@ -68,6 +72,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { const [tags, setTags] = useState() const [createdAt, setCreatedAt] = useState() 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() const [sig, setSig] = useState() @@ -99,6 +104,18 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { if (!meta) return ;(async function () { 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 { kind, tags, created_at, pubkey, id, sig, content } = @@ -265,6 +282,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { tags, createdAt, submittedBy, + exportedBy, id, sig, signers, diff --git a/src/hooks/useSigitProfiles.tsx b/src/hooks/useSigitProfiles.tsx deleted file mode 100644 index 88d6c50..0000000 --- a/src/hooks/useSigitProfiles.tsx +++ /dev/null @@ -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([...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 -} diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index 9402e97..3a1f10e 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -38,7 +38,7 @@ export const MainLayout = () => { const hasSubscribed = useRef(false) useEffect(() => { - const metadataController = new MetadataController() + const metadataController = MetadataController.getInstance() const logout = () => { dispatch( diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index f0bd6b9..dd17450 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -36,44 +36,26 @@ import { npubToHex, queryNip05, sendNotification, - shorten, signEventForMetaFile, updateUsersAppData, - uploadToFileStorage + uploadToFileStorage, + DEFAULT_TOOLBOX } from '../../utils' import { Container } from '../../components/Container' 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 { Mark } from '../../types/mark.ts' import { StickySideColumns } from '../../layouts/StickySideColumns.tsx' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { - fa1, - faBriefcase, - faCalendarDays, - faCheckDouble, - faCircleDot, - faClock, - faCreditCard, faEllipsis, faEye, faFile, faFileCirclePlus, faGripLines, - faHeading, - faIdCard, - faImage, - faPaperclip, faPen, - faPhone, faPlus, - faSignature, - faSquareCaretDown, - faSquareCheck, - faStamp, - faT, - faTableCellsLarge, faToolbox, faTrash, faUpload @@ -113,6 +95,8 @@ export const CreatePage = () => { const [error, setError] = useState() const [users, setUsers] = useState([]) + 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) @@ -124,116 +108,7 @@ export const CreatePage = () => { const [drawnFiles, setDrawnFiles] = useState([]) const [selectedTool, setSelectedTool] = useState() - const [toolbox] = useState([ - { - 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 - } - ]) + const [toolbox] = useState(DEFAULT_TOOLBOX) /** * Changes the drawing tool @@ -252,7 +127,7 @@ export const CreatePage = () => { useEffect(() => { users.forEach((user) => { if (!(user.pubkey in metadata)) { - const metadataController = new MetadataController() + const metadataController = MetadataController.getInstance() const handleMetadataEvent = (event: Event) => { const metadataContent = @@ -647,6 +522,11 @@ export const CreatePage = () => { } 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) } @@ -672,9 +552,6 @@ export const CreatePage = () => { }, zipUrl: string ) => { - const signers = users.filter((user) => user.role === UserRole.signer) - const viewers = users.filter((user) => user.role === UserRole.viewer) - const content: CreateSignatureEventContent = { signers: signers.map((signer) => hexToNpub(signer.pubkey)), viewers: viewers.map((viewer) => hexToNpub(viewer.pubkey)), @@ -703,9 +580,6 @@ export const CreatePage = () => { // Send notifications to signers and viewers 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 const receivers = ( signers.length > 0 @@ -787,7 +661,7 @@ export const CreatePage = () => { toast.error('Failed to publish notifications') }) - navigate(appPrivateRoutes.sign, { state: { meta: meta } }) + navigate(appPrivateRoutes.sign, { state: { meta } }) } else { const zip = new JSZip() @@ -914,7 +788,6 @@ export const CreatePage = () => {
{
-
{toolbox.map((drawTool: DrawTool, index: number) => { @@ -987,6 +857,10 @@ export const CreatePage = () => { })}
+ + {!!error && ( {error} )} @@ -1010,7 +884,6 @@ export const CreatePage = () => { } type DisplayUsersProps = { - metadata: { [key: string]: ProfileMetadata } users: User[] handleUserRoleChange: (role: UserRole, pubkey: string) => void handleRemoveUser: (pubkey: string) => void @@ -1018,7 +891,6 @@ type DisplayUsersProps = { } const DisplayUser = ({ - metadata, users, handleUserRoleChange, handleRemoveUser, @@ -1032,7 +904,6 @@ const DisplayUser = ({ .map((user, index) => ( void handleRemoveUser: (pubkey: string) => void @@ -1078,7 +947,6 @@ type SignerCounterpartProps = CounterpartProps & { } const SignerCounterpart = ({ - userMeta, user, index, moveSigner, @@ -1171,7 +1039,6 @@ const SignerCounterpart = ({ @@ -1180,7 +1047,6 @@ const SignerCounterpart = ({ } const Counterpart = ({ - userMeta, user, handleUserRoleChange, handleRemoveUser @@ -1188,15 +1054,7 @@ const Counterpart = ({ return ( <>
- +
+ + )} + + )} {isNostrExtensionAvailable && ( <> diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index ca4bb87..905e7d0 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -27,7 +27,7 @@ export const ProfilePage = () => { const { npub } = useParams() - const metadataController = useMemo(() => new MetadataController(), []) + const metadataController = useMemo(() => MetadataController.getInstance(), []) const [pubkey, setPubkey] = useState() const [nostrJoiningBlock, setNostrJoiningBlock] = diff --git a/src/pages/settings/profile/index.tsx b/src/pages/settings/profile/index.tsx index 9b01a72..245ae77 100644 --- a/src/pages/settings/profile/index.tsx +++ b/src/pages/settings/profile/index.tsx @@ -12,7 +12,7 @@ import { useTheme } from '@mui/material' 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 { toast } from 'react-toastify' import { MetadataController, NostrController } from '../../../controllers' @@ -41,7 +41,7 @@ export const ProfileSettingsPage = () => { const dispatch: Dispatch = useDispatch() - const metadataController = useMemo(() => new MetadataController(), []) + const metadataController = MetadataController.getInstance() const nostrController = NostrController.getInstance() const [pubkey, setPubkey] = useState() diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index 8720954..63e71dd 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -33,7 +33,8 @@ import { sendNotification, signEventForMetaFile, updateUsersAppData, - findOtherUserMarks + findOtherUserMarks, + timeout } from '../../utils' import { Container } from '../../components/Container' import { DisplayMeta } from './internal/displayMeta' @@ -276,17 +277,10 @@ export const SignPage = () => { setAuthUrl(url) }) - // Set up timeout promise to handle encryption timeout - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error('Timeout occurred')) - }, 60000) // Timeout duration = 60 seconds - }) - - // decrypt the encryptionKey, with timeout + // decrypt the encryptionKey, with timeout (duration = 60 seconds) const encryptionKey = await Promise.race([ nostrController.nip04Decrypt(sender, key), - timeoutPromise + timeout(60000) ]) .then((res) => { return res @@ -468,20 +462,20 @@ export const SignPage = () => { const fileNames = Object.values(zip.files) .filter((entry) => entry.name.startsWith('files/') && !entry.dir) .map((entry) => entry.name) - .map((entry) => entry.replace(/^files\//, '')) - // generate hashes for all entries in files folder of zipArchive - // 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( zip, - fileName, + zipFilePath, 'arraybuffer' ) + const fileName = zipFilePath.replace(/^files\//, '') if (arrayBuffer) { 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) if (hash) { fileHashes[fileName] = hash diff --git a/src/pages/sign/internal/displayMeta.tsx b/src/pages/sign/internal/displayMeta.tsx index bb298c6..9335495 100644 --- a/src/pages/sign/internal/displayMeta.tsx +++ b/src/pages/sign/internal/displayMeta.tsx @@ -32,7 +32,7 @@ import { useState, useEffect } from 'react' import { toast } from 'react-toastify' import { UserAvatar } from '../../../components/UserAvatar' import { MetadataController } from '../../../controllers' -import { npubToHex, shorten, hexToNpub, parseJson } from '../../../utils' +import { npubToHex, hexToNpub, parseJson } from '../../../utils' import styles from '../style.module.scss' import { SigitFile } from '../../../utils/file' @@ -105,7 +105,7 @@ export const DisplayMeta = ({ }, [signers, viewers]) useEffect(() => { - const metadataController = new MetadataController() + const metadataController = MetadataController.getInstance() const hexKeys: string[] = [ npubToHex(submittedBy)!, @@ -167,20 +167,7 @@ export const DisplayMeta = ({ Submitted By - {(function () { - const profile = metadata[submittedBy] - return ( - - ) - })()} + { const theme = useTheme() - const userMeta = metadata[user.pubkey] const [userStatus, setUserStatus] = useState(UserStatus.Pending) const [prevSignatureStatus, setPreviousSignatureStatus] = useState(PrevSignatureValidationEnum.Pending) @@ -370,15 +355,7 @@ const DisplayUser = ({ return ( - + {user.role} diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index e869c98..de66991 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -1,16 +1,11 @@ -import { Box, Button, Tooltip, Typography } from '@mui/material' +import { Box, Button, Typography } from '@mui/material' import JSZip from 'jszip' import { MuiFileInput } from 'mui-file-input' -import { Event, verifyEvent } from 'nostr-tools' import { useEffect, useRef, useState } from 'react' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' import { NostrController } from '../../controllers' -import { - CreateSignatureEventContent, - DocSignatureEvent, - Meta -} from '../../types' +import { DocSignatureEvent, Meta } from '../../types' import { decryptArrayBuffer, extractMarksFromSignedMeta, @@ -20,7 +15,6 @@ import { parseJson, readContentOfZipEntry, signEventForMetaFile, - shorten, getCurrentUserFiles } from '../../utils' import styles from './style.module.scss' @@ -28,7 +22,6 @@ import { useLocation } from 'react-router-dom' import axios from 'axios' import { addMarks, - convertToPdfBlob, FONT_SIZE, FONT_TYPE, groupMarksByFileNamePage, @@ -42,9 +35,6 @@ import { Container } from '../../components/Container' import { useSigitMeta } from '../../hooks/useSigitMeta.tsx' import { StickySideColumns } from '../../layouts/StickySideColumns.tsx' import { UsersDetails } from '../../components/UsersDetails.tsx/index.tsx' -import { UserAvatar } from '../../components/UserAvatar/index.tsx' -import { useSigitProfiles } from '../../hooks/useSigitProfiles.tsx' -import { TooltipChild } from '../../components/TooltipChild.tsx' import FileList from '../../components/FileList' import { CurrentUserFile } from '../../types/file.ts' import { Mark } from '../../types/mark.ts' @@ -163,12 +153,26 @@ const SlimPdfView = ({ export const VerifyPage = () => { 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 * 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(null) + useEffect(() => { + if (uploadedZip) { + setSelectedFile(uploadedZip) + } + }, [uploadedZip]) + const [meta, setMeta] = useState(metaInNavState) const { submittedBy, zipUrl, @@ -179,44 +183,22 @@ export const VerifyPage = () => { parsedSignatureEvents } = useSigitMeta(meta) - const profiles = useSigitProfiles([ - ...(submittedBy ? [submittedBy] : []), - ...signers, - ...viewers - ]) - - const [isLoading, setIsLoading] = useState(false) - const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') - - const [selectedFile, setSelectedFile] = useState(null) + const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({}) + const [currentFile, setCurrentFile] = useState(null) const [currentFileHashes, setCurrentFileHashes] = useState<{ [key: string]: string | null }>({}) - const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({}) - const [currentFile, setCurrentFile] = useState(null) - const [signatureFileHashes, setSignatureFileHashes] = useState<{ - [key: string]: string - }>(fileHashes) - - useEffect(() => { - setSignatureFileHashes(fileHashes) - }, [fileHashes]) useEffect(() => { if (Object.entries(files).length > 0) { - const tmp = getCurrentUserFiles(files, fileHashes, signatureFileHashes) + const tmp = getCurrentUserFiles(files, currentFileHashes, fileHashes) setCurrentFile(tmp[0]) } - }, [signatureFileHashes, fileHashes, files]) - - const usersPubkey = useSelector((state: State) => state.auth.usersPubkey) - const nostrController = NostrController.getInstance() + }, [currentFileHashes, fileHashes, files]) useEffect(() => { - if (uploadedZip) { - setSelectedFile(uploadedZip) - } else if (meta && encryptionKey) { + if (metaInNavState && encryptionKey) { const processSigit = async () => { setIsLoading(true) @@ -301,7 +283,7 @@ export const VerifyPage = () => { processSigit() } - }, [encryptionKey, meta, uploadedZip, zipUrl]) + }, [encryptionKey, metaInNavState, zipUrl]) const handleVerify = async () => { if (!selectedFile) return @@ -315,6 +297,7 @@ export const VerifyPage = () => { if (!zip) return + const files: { [filename: string]: SigitFile } = {} const fileHashes: { [key: string]: string | null } = {} const fileNames = Object.values(zip.files) .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 // 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( zip, - fileName, + zipFilePath, 'arraybuffer' ) + const fileName = zipFilePath.replace(/^files\//, '') if (arrayBuffer) { + files[fileName] = await convertToSigitFile(arrayBuffer, fileName) const hash = await getHash(arrayBuffer) if (hash) { - fileHashes[fileName.replace(/^files\//, '')] = hash + fileHashes[fileName] = hash } } else { - fileHashes[fileName.replace(/^files\//, '')] = null + fileHashes[fileName] = null } } + setFiles(files) setCurrentFileHashes(fileHashes) setLoadingSpinnerDesc('Parsing meta.json') @@ -368,43 +354,7 @@ export const VerifyPage = () => { if (!parsedMetaJson) return - const createSignatureEvent = await parseJson( - 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( - 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 + setMeta(parsedMetaJson) setIsLoading(false) } @@ -448,8 +398,7 @@ export const VerifyPage = () => { for (const [fileName, file] of Object.entries(files)) { if (file.isPdf) { // Draw marks into PDF file and generate a brand new blob - const pages = await addMarks(file, marksByPage[fileName]) - const blob = await convertToPdfBlob(pages) + const blob = await addMarks(file, marksByPage[fileName]) zip.file(`files/${fileName}`, blob) } else { zip.file(`files/${fileName}`, file) @@ -479,47 +428,6 @@ export const VerifyPage = () => { 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 ( - - - - - - ) - } else { - toast.error(`Invalid export signature!`) - return ( - - Invalid export signature - - ) - } - } catch (error) { - console.error(`An error occurred wile parsing exportSignature`, error) - return null - } - } - return ( <> {isLoading && } @@ -554,22 +462,19 @@ export const VerifyPage = () => { {meta && ( - {currentFile !== null && ( - - )} - {displayExportedBy()} - + currentFile !== null && ( + + ) } right={} leftIcon={faFileDownload} @@ -578,11 +483,7 @@ export const VerifyPage = () => { > diff --git a/src/pages/verify/style.module.scss b/src/pages/verify/style.module.scss index af93107..b63ba60 100644 --- a/src/pages/verify/style.module.scss +++ b/src/pages/verify/style.module.scss @@ -61,6 +61,6 @@ [data-dev='true'] { .mark { - border: 1px dotted black; + outline: 1px dotted black; } } diff --git a/src/types/errors/TimeoutError.ts b/src/types/errors/TimeoutError.ts new file mode 100644 index 0000000..5bd31c5 --- /dev/null +++ b/src/types/errors/TimeoutError.ts @@ -0,0 +1,6 @@ +export class TimeoutError extends Error { + constructor() { + super('Timeout') + this.name = this.constructor.name + } +} diff --git a/src/utils/const.ts b/src/utils/const.ts index 83aca9e..a512c2f 100644 --- a/src/utils/const.ts +++ b/src/utils/const.ts @@ -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_BLOSSOM = 'https://blossom.sigit.io' + export const DEFAULT_LOOK_UP_RELAY_LIST = [ SIGIT_RELAY, 'wss://user.kindpag.es', diff --git a/src/utils/dvm.ts b/src/utils/dvm.ts index 8995ae7..c2f33e8 100644 --- a/src/utils/dvm.ts +++ b/src/utils/dvm.ts @@ -13,7 +13,7 @@ import { setRelayInfoAction } from '../store/actions' export const getNostrJoiningBlockNumber = async ( hexKey: string ): Promise => { - const metadataController = new MetadataController() + const metadataController = MetadataController.getInstance() const relaySet = await metadataController.findRelayListMetadata(hexKey) diff --git a/src/utils/file.ts b/src/utils/file.ts index 63d40e5..6156858 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -4,7 +4,6 @@ import { MOST_COMMON_MEDIA_TYPES } from './const.ts' import { extractMarksFromSignedMeta } from './mark.ts' import { addMarks, - convertToPdfBlob, groupMarksByFileNamePage, isPdf, pdfToImages @@ -22,8 +21,7 @@ export const getZipWithFiles = async ( for (const [fileName, file] of Object.entries(files)) { if (file.isPdf) { // Handle PDF Files - const pages = await addMarks(file, marksByFileNamePage[fileName]) - const blob = await convertToPdfBlob(pages) + const blob = await addMarks(file, marksByFileNamePage[fileName]) zip.file(`files/${fileName}`, blob) } else { // Handle other files diff --git a/src/utils/mark.ts b/src/utils/mark.ts index 44540c4..ac80623 100644 --- a/src/utils/mark.ts +++ b/src/utils/mark.ts @@ -3,6 +3,27 @@ import { hexToNpub } from './nostr.ts' import { Meta, SignedEventContent } from '../types' import { Event } from 'nostr-tools' 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. @@ -131,6 +152,121 @@ const findOtherUserMarks = (marks: Mark[], pubkey: string): Mark[] => { 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 { getCurrentUserMarks, filterMarksByPubkey, diff --git a/src/utils/misc.ts b/src/utils/misc.ts index f427b78..c84231b 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -16,6 +16,8 @@ import { CreateSignatureEventContent, Meta } from '../types' import { hexToNpub, unixNow } from './nostr' import { parseJson } from './string' 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. @@ -25,12 +27,18 @@ import { hexToBytes } from '@noble/hashes/utils' */ export const uploadToFileStorage = async (file: File) => { // 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 = { kind: 24242, content: 'Authorize Upload', created_at: unixNow(), tags: [ ['t', 'upload'], + ['x', hash], ['expiration', String(unixNow() + 60 * 5)], // Set expiration time to 5 minutes from now ['name', file.name], ['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 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 - const response = await axios.put(`${FILE_STORAGE_URL}/upload`, file, { + const response = await axios.put(`${SIGIT_BLOSSOM}/upload`, file, { headers: { Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)), // Set authorization header 'Content-Type': 'application/sigit' // Set content type header diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index 0a3e052..ec42325 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -35,6 +35,7 @@ import { getDefaultRelayMap } from './relays' import { parseJson, removeLeadingSlash } from './string' import { timeout } from './utils' import { getHash } from './hash' +import { SIGIT_BLOSSOM } from './const.ts' /** * Generates a `d` tag for userAppData @@ -365,7 +366,7 @@ export const getUsersAppData = async (): Promise => { // Check if relayMap is undefined in the Redux store if (!relayMap) { // If relayMap is not present, fetch relay list metadata - const metadataController = new MetadataController() + const metadataController = MetadataController.getInstance() const relaySet = await metadataController .findRelayListMetadata(usersPubkey) .catch((err) => { @@ -723,6 +724,11 @@ const uploadUserAppDataToBlossom = async ( 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 const event: EventTemplate = { kind: 24242, @@ -730,6 +736,7 @@ const uploadUserAppDataToBlossom = async ( created_at: unixNow(), tags: [ ['t', 'upload'], + ['x', hash], ['expiration', String(unixNow() + 60 * 5)], // Set expiration time to 5 minutes from now ['name', file.name], ['size', String(file.size)] @@ -739,11 +746,8 @@ const uploadUserAppDataToBlossom = async ( // Finalize the event with the private key 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 - const response = await axios.put(`${FILE_STORAGE_URL}/upload`, file, { + const response = await axios.put(`${SIGIT_BLOSSOM}/upload`, file, { headers: { 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) => { // Instantiate the MetadataController to retrieve relay list metadata - const metadataController = new MetadataController() + const metadataController = MetadataController.getInstance() const relaySet = await metadataController .findRelayListMetadata(pubkey) .catch((err) => { @@ -939,7 +943,7 @@ export const sendNotification = async (receiver: string, meta: Meta) => { const wrappedEvent = createWrap(unsignedEvent, receiver) // Instantiate the MetadataController to retrieve relay list metadata - const metadataController = new MetadataController() + const metadataController = MetadataController.getInstance() const relaySet = await metadataController .findRelayListMetadata(receiver) .catch((err) => { diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts index 763d582..c3e381d 100644 --- a/src/utils/pdf.ts +++ b/src/utils/pdf.ts @@ -1,7 +1,6 @@ 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 * as PDFJS from 'pdfjs-dist' import PDFJSWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?worker' if (!PDFJS.GlobalWorkerOptions.workerPort) { @@ -10,18 +9,23 @@ if (!PDFJS.GlobalWorkerOptions.workerPort) { 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 * correlate font size used at the time of filling in / drawing on the PDF * 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 /** * 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 @@ -115,56 +119,28 @@ export const pdfToImages = async ( /** * 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 - * of a PDF page with completed and signed marks from all users + * Returns a PDF blob with embedded, completed and signed marks from all users as text */ export const addMarks = async ( file: File, marksPerPage: { [key: string]: Mark[] } ) => { const p = await readPdf(file) - const pdf = await PDFJS.getDocument(p).promise - const canvas = document.createElement('canvas') + const pdf = await PDFDocument.load(p) + 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 - - if (marksPerPage && Object.hasOwn(marksPerPage, i)) { - marksPerPage[i]?.forEach((mark) => draw(mark, context)) - } - - images.push(canvas.toDataURL()) + for (let i = 0; i < pages.length; i++) { + if (marksPerPage && Object.hasOwn(marksPerPage, i)) { + marksPerPage[i]?.forEach((mark) => + drawMarkText(mark, pages[i], robotoFont) + ) } } - canvas.remove() + const blob = await pdf.save() - return images -} - -/** - * 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 - } - } + return blob } /** @@ -177,6 +153,7 @@ export const hasValue = (mark: Mark): boolean => !!mark.value * Draws a Mark on a Canvas representation of a PDF Page * @param mark to be drawn * @param ctx a Canvas representation of a specific PDF Page + * @deprecated use drawMarkText */ export const draw = (mark: Mark, ctx: CanvasRenderingContext2D) => { const { location } = mark @@ -191,27 +168,37 @@ export const draw = (mark: Mark, ctx: CanvasRenderingContext2D) => { } /** - * Takes an array of encoded PDF pages and returns a blob that is a complete PDF file - * @param markedPdfPages + * Draws a Mark on a PDF Page + * @param mark to be drawn + * @param page PDF Page + * @param font embedded font */ -export const convertToPdfBlob = async ( - markedPdfPages: string[] -): Promise => { - const pdfDoc = await PDFDocument.create() +export const drawMarkText = (mark: Mark, page: PDFPage, font: PDFFont) => { + const { location } = mark + const { height } = page.getSize() - for (const page of markedPdfPages) { - const pngImage = await pdfDoc.embedPng(page) - const p = pdfDoc.addPage([pngImage.width, pngImage.height]) - p.drawImage(pngImage, { - x: 0, - y: 0, - width: pngImage.width, - height: pngImage.height - }) - } + // Convert the mark location origin (top, left) to PDF origin (bottom, left) + const x = location.left + const y = height - location.top - const pdfBytes = await pdfDoc.save() - return new Blob([pdfBytes], { type: 'application/pdf' }) + // Adjust y-coordinate for the text, drawText's y is the baseline for the font + // We start from the y (top location border) and we need to bump it down + // We move font baseline by the difference between rendered height and actual height (gap) + // 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 + }) } /** @@ -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 +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index f32e14e..11053b9 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,3 +1,4 @@ +import { TimeoutError } from '../types/errors/TimeoutError.ts' import { CurrentUserFile } from '../types/file.ts' import { SigitFile } from './file.ts' @@ -34,7 +35,7 @@ export const isOnline = async () => { try { // 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 // 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 setTimeout(() => { // Reject the promise with an Error indicating a timeout - reject(new Error('Timeout')) + reject(new TimeoutError()) }, ms) // Timeout duration in milliseconds }) }