Release Oct 7th #218

Merged
b merged 20 commits from staging into main 2024-10-08 09:02:11 +00:00
26 changed files with 717 additions and 505 deletions
Showing only changes of commit e33996c1f9 - Show all commits

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

@ -100,10 +100,10 @@ li {
// - first-child Header height, default body padding, and center content border (10px) and padding (10px) // - first-child Header height, default body padding, and center content border (10px) and padding (10px)
// - others We don't include border and padding and scroll to the top of the image // - others We don't include border and padding and scroll to the top of the image
&:first-child { &:first-child {
scroll-margin-top: $header-height + $body-vertical-padding + 20px; scroll-margin-top: $body-vertical-padding + 20px;
} }
&:not(:first-child) { &:not(:first-child) {
scroll-margin-top: $header-height + $body-vertical-padding; scroll-margin-top: $body-vertical-padding;
} }
} }
@ -135,14 +135,20 @@ 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;
scroll-margin-top: $header-height + $body-vertical-padding; font-size: 16px;
color: black;
outline: 1px solid transparent;
justify-content: start;
align-items: start;
scroll-margin-top: $body-vertical-padding;
} }
[data-dev='true'] { [data-dev='true'] {

Binary file not shown.

View File

@ -28,14 +28,17 @@ import {
} from '../../routes' } from '../../routes'
import { import {
clearAuthToken, clearAuthToken,
getProfileUsername,
hexToNpub, hexToNpub,
saveNsecBunkerDelegatedKey, saveNsecBunkerDelegatedKey
shorten
} from '../../utils' } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { setUserRobotImage } from '../../store/userRobotImage/action' import { setUserRobotImage } from '../../store/userRobotImage/action'
import { Container } from '../Container' import { Container } from '../Container'
import { ButtonIcon } from '../ButtonIcon' import { ButtonIcon } from '../ButtonIcon'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faClose } from '@fortawesome/free-solid-svg-icons'
import useMediaQuery from '@mui/material/useMediaQuery'
const metadataController = MetadataController.getInstance() const metadataController = MetadataController.getInstance()
@ -55,9 +58,8 @@ export const AppBar = () => {
useEffect(() => { useEffect(() => {
if (metadataState) { if (metadataState) {
if (metadataState.content) { if (metadataState.content) {
const { picture, display_name, name } = JSON.parse( const profileMetadata = JSON.parse(metadataState.content)
metadataState.content const { picture } = profileMetadata
)
if (picture || userRobotImage) { if (picture || userRobotImage) {
setUserAvatar(picture || userRobotImage) setUserAvatar(picture || userRobotImage)
@ -67,7 +69,7 @@ export const AppBar = () => {
? hexToNpub(authState.usersPubkey) ? hexToNpub(authState.usersPubkey)
: '' : ''
setUsername(shorten(display_name || name || npub, 7)) setUsername(getProfileUsername(npub, profileMetadata))
} else { } else {
setUserAvatar(userRobotImage || '') setUserAvatar(userRobotImage || '')
setUsername('') setUsername('')
@ -118,125 +120,152 @@ export const AppBar = () => {
} }
const isAuthenticated = authState?.loggedIn === true const isAuthenticated = authState?.loggedIn === true
const matches = useMediaQuery('(max-width:767px)')
const [isBannerVisible, setIsBannerVisible] = useState(true)
const handleBannerHide = () => {
setIsBannerVisible(false)
}
return ( return (
<AppBarMui <>
position="fixed" {isAuthenticated && isBannerVisible && (
className={styles.AppBar} <div className={styles.banner}>
sx={{ <Container>
boxShadow: 'none' <div className={styles.bannerInner}>
}} <p className={styles.bannerText}>
> SIGit is currently Alpha software (available for internal
<Container> testing), use at your own risk!
<Toolbar className={styles.toolbar} disableGutters={true}> </p>
<Box className={styles.logoWrapper}>
<img src="/logo.svg" alt="Logo" onClick={() => navigate('/')} />
</Box>
<Box className={styles.rightSideBox}>
{!isAuthenticated && (
<Button <Button
startIcon={<ButtonIcon />} aria-label={`close banner`}
onClick={() => { variant="text"
navigate(appPublicRoutes.nostr) onClick={handleBannerHide}
}}
variant="contained"
> >
LOGIN <FontAwesomeIcon icon={faClose} />
</Button> </Button>
)} </div>
</Container>
</div>
)}
<AppBarMui
position={matches ? 'sticky' : 'static'}
className={styles.AppBar}
sx={{
boxShadow: 'none'
}}
>
<Container>
<Toolbar className={styles.toolbar} disableGutters={true}>
<Box className={styles.logoWrapper}>
<img src="/logo.svg" alt="Logo" onClick={() => navigate('/')} />
</Box>
{isAuthenticated && ( <Box className={styles.rightSideBox}>
<> {!isAuthenticated && (
<Username <Button
username={username} startIcon={<ButtonIcon />}
avatarContent={userAvatar} onClick={() => {
handleClick={handleOpenUserMenu} navigate(appPublicRoutes.nostr)
/>
<Menu
id="menu-appbar"
anchorEl={anchorElUser}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center'
}} }}
keepMounted variant="contained"
transformOrigin={{
vertical: 'top',
horizontal: 'center'
}}
open={!!anchorElUser}
onClose={handleCloseUserMenu}
> >
<MenuItem LOGIN
sx={{ </Button>
justifyContent: 'center', )}
display: { md: 'none' },
fontWeight: 500,
fontSize: '14px',
color: 'var(--text-color)'
}}
>
<Typography variant="h6">{username}</Typography>
</MenuItem>
<MenuItem
onClick={handleProfile}
sx={{
justifyContent: 'center'
}}
>
Profile
</MenuItem>
<MenuItem
onClick={() => {
setAnchorElUser(null)
navigate(appPrivateRoutes.settings) {isAuthenticated && (
<>
<Username
username={username}
avatarContent={userAvatar}
handleClick={handleOpenUserMenu}
/>
<Menu
id="menu-appbar"
anchorEl={anchorElUser}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center'
}} }}
sx={{ keepMounted
justifyContent: 'center' transformOrigin={{
vertical: 'top',
horizontal: 'center'
}} }}
> open={!!anchorElUser}
Settings onClose={handleCloseUserMenu}
</MenuItem>
<MenuItem
onClick={() => {
setAnchorElUser(null)
navigate(appPublicRoutes.verify)
}}
sx={{
justifyContent: 'center'
}}
>
Verify
</MenuItem>
<Link
to={appPublicRoutes.source}
target="_blank"
style={{ color: 'inherit', textDecoration: 'inherit' }}
> >
<MenuItem <MenuItem
sx={{
justifyContent: 'center',
display: { md: 'none' },
fontWeight: 500,
fontSize: '14px',
color: 'var(--text-color)'
}}
>
<Typography variant="h6">{username}</Typography>
</MenuItem>
<MenuItem
onClick={handleProfile}
sx={{ sx={{
justifyContent: 'center' justifyContent: 'center'
}} }}
> >
Source Profile
</MenuItem> </MenuItem>
</Link> <MenuItem
<MenuItem onClick={() => {
onClick={handleLogout} setAnchorElUser(null)
sx={{
justifyContent: 'center' navigate(appPrivateRoutes.settings)
}} }}
> sx={{
Logout justifyContent: 'center'
</MenuItem> }}
</Menu> >
</> Settings
)} </MenuItem>
</Box> <MenuItem
</Toolbar> onClick={() => {
</Container> setAnchorElUser(null)
</AppBarMui>
navigate(appPublicRoutes.verify)
}}
sx={{
justifyContent: 'center'
}}
>
Verify
</MenuItem>
<Link
to={appPublicRoutes.source}
target="_blank"
style={{ color: 'inherit', textDecoration: 'inherit' }}
>
<MenuItem
sx={{
justifyContent: 'center'
}}
>
Source
</MenuItem>
</Link>
<MenuItem
onClick={handleLogout}
sx={{
justifyContent: 'center'
}}
>
Logout
</MenuItem>
</Menu>
</>
)}
</Box>
</Toolbar>
</Container>
</AppBarMui>
</>
) )
} }

View File

@ -34,3 +34,42 @@
justify-content: flex-end; justify-content: flex-end;
} }
} }
.banner {
color: #ffffff;
background-color: $primary-main;
}
.bannerInner {
display: flex;
gap: 10px;
padding-block: 10px;
z-index: 1;
width: 100%;
justify-content: space-between;
flex-direction: row;
button {
min-width: 44px;
color: inherit;
}
&:hover,
&.active,
&:focus-within {
background: $primary-main;
color: inherit;
button {
color: inherit;
}
}
}
.bannerText {
margin-left: 54px;
flex-grow: 1;
text-align: center;
}

View File

@ -11,15 +11,16 @@ import styles from './style.module.scss'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { ProfileMetadata, User, UserRole } from '../../types' import { ProfileMetadata, User, UserRole } from '../../types'
import { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing' import { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing'
import { truncate } from 'lodash' import { hexToNpub, npubToHex, getProfileUsername } from '../../utils'
import { settleAllFullfilfedPromises, hexToNpub, npubToHex } from '../../utils' import { 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 { UserAvatar } from '../UserAvatar'
import _ from 'lodash'
const DEFAULT_START_SIZE = { const DEFAULT_START_SIZE = {
width: 140, width: 140,
@ -27,52 +28,50 @@ const DEFAULT_START_SIZE = {
} as const } as const
interface Props { interface Props {
selectedFiles: File[]
users: User[] users: User[]
metadata: { [key: string]: ProfileMetadata } metadata: { [key: string]: ProfileMetadata }
onDrawFieldsChange: (sigitFiles: SigitFile[]) => void sigitFiles: SigitFile[]
setSigitFiles: React.Dispatch<React.SetStateAction<SigitFile[]>>
selectedTool?: DrawTool selectedTool?: DrawTool
} }
export const DrawPDFFields = (props: Props) => { export const DrawPDFFields = (props: Props) => {
const { selectedFiles, selectedTool, onDrawFieldsChange, users } = props const { selectedTool, sigitFiles, setSigitFiles, users } = props
const { to, from } = useScale()
const [sigitFiles, setSigitFiles] = useState<SigitFile[]>([]) const signers = users.filter((u) => u.role === UserRole.signer)
const [parsingPdf, setIsParsing] = useState<boolean>(false) const defaultSignerNpub = signers.length ? hexToNpub(signers[0].pubkey) : ''
const [lastSigner, setLastSigner] = useState(defaultSignerNpub)
/**
* Return first pubkey that is present in the signers list
* @param pubkeys
* @returns available pubkey or empty string
*/
const getAvailableSigner = (...pubkeys: string[]) => {
const availableSigner: string | undefined = pubkeys.find((pubkey) =>
signers.some((s) => s.pubkey === npubToHex(pubkey))
)
return availableSigner || ''
}
const { to, from } = useScale()
const [mouseState, setMouseState] = useState<MouseState>({ const [mouseState, setMouseState] = useState<MouseState>({
clicked: false clicked: false
}) })
const [activeDrawField, setActiveDrawField] = useState<number>() const [activeDrawnField, setActiveDrawnField] = useState<{
fileIndex: number
useEffect(() => { pageIndex: number
if (selectedFiles) { drawnFieldIndex: number
/** }>()
* Reads the binary files and converts to internal file type const isActiveDrawnField = (
* and sets to a state (adds images if it's a PDF) fileIndex: number,
*/ pageIndex: number,
const parsePages = async () => { drawnFieldIndex: number
const files = await settleAllFullfilfedPromises( ) =>
selectedFiles, activeDrawnField?.fileIndex === fileIndex &&
getSigitFile activeDrawnField?.pageIndex === pageIndex &&
) activeDrawnField?.drawnFieldIndex === drawnFieldIndex
setSigitFiles(files)
}
setIsParsing(true)
parsePages().finally(() => {
setIsParsing(false)
})
}
}, [selectedFiles])
useEffect(() => {
if (sigitFiles) onDrawFieldsChange(sigitFiles)
}, [onDrawFieldsChange, sigitFiles])
/** /**
* Drawing events * Drawing events
@ -99,7 +98,12 @@ export const DrawPDFFields = (props: Props) => {
* @param event Pointer event * @param event Pointer event
* @param page PdfPage where press happened * @param page PdfPage where press happened
*/ */
const handlePointerDown = (event: React.PointerEvent, page: PdfPage) => { const handlePointerDown = (
event: React.PointerEvent,
page: PdfPage,
fileIndex: number,
pageIndex: number
) => {
// Proceed only if left click // Proceed only if left click
if (event.button !== 0) return if (event.button !== 0) return
@ -114,12 +118,17 @@ export const DrawPDFFields = (props: Props) => {
top: to(page.width, y), top: to(page.width, y),
width: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.width, width: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.width,
height: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.height, height: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.height,
counterpart: '', counterpart: getAvailableSigner(lastSigner, defaultSignerNpub),
type: selectedTool.identifier type: selectedTool.identifier
} }
page.drawnFields.push(newField) page.drawnFields.push(newField)
setActiveDrawnField({
fileIndex,
pageIndex,
drawnFieldIndex: page.drawnFields.length - 1
})
setMouseState((prev) => { setMouseState((prev) => {
return { return {
...prev, ...prev,
@ -188,6 +197,8 @@ export const DrawPDFFields = (props: Props) => {
*/ */
const handleDrawnFieldPointerDown = ( const handleDrawnFieldPointerDown = (
event: React.PointerEvent, event: React.PointerEvent,
fileIndex: number,
pageIndex: number,
drawnFieldIndex: number drawnFieldIndex: number
) => { ) => {
event.stopPropagation() event.stopPropagation()
@ -197,7 +208,7 @@ export const DrawPDFFields = (props: Props) => {
const drawingRectangleCoords = getPointerCoordinates(event) const drawingRectangleCoords = getPointerCoordinates(event)
setActiveDrawField(drawnFieldIndex) setActiveDrawnField({ fileIndex, pageIndex, drawnFieldIndex })
setMouseState({ setMouseState({
dragging: true, dragging: true,
clicked: false, clicked: false,
@ -253,13 +264,15 @@ export const DrawPDFFields = (props: Props) => {
*/ */
const handleResizePointerDown = ( const handleResizePointerDown = (
event: React.PointerEvent, event: React.PointerEvent,
fileIndex: number,
pageIndex: number,
drawnFieldIndex: number drawnFieldIndex: number
) => { ) => {
// Proceed only if left click // Proceed only if left click
if (event.button !== 0) return if (event.button !== 0) return
event.stopPropagation() event.stopPropagation()
setActiveDrawField(drawnFieldIndex) setActiveDrawnField({ fileIndex, pageIndex, drawnFieldIndex })
setMouseState({ setMouseState({
resizing: true resizing: true
}) })
@ -368,7 +381,7 @@ export const DrawPDFFields = (props: Props) => {
handlePointerMove(event, page) handlePointerMove(event, page)
}} }}
onPointerDown={(event) => { onPointerDown={(event) => {
handlePointerDown(event, page) handlePointerDown(event, page, fileIndex, pageIndex)
}} }}
draggable="false" draggable="false"
src={page.image} src={page.image}
@ -380,7 +393,12 @@ export const DrawPDFFields = (props: Props) => {
<div <div
key={drawnFieldIndex} key={drawnFieldIndex}
onPointerDown={(event) => onPointerDown={(event) =>
handleDrawnFieldPointerDown(event, drawnFieldIndex) handleDrawnFieldPointerDown(
event,
fileIndex,
pageIndex,
drawnFieldIndex
)
} }
onPointerMove={(event) => { onPointerMove={(event) => {
handleDrawnFieldPointerMove(event, drawnField, page.width) handleDrawnFieldPointerMove(event, drawnField, page.width)
@ -390,7 +408,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)),
@ -401,14 +419,33 @@ export const DrawPDFFields = (props: Props) => {
touchAction: 'none', touchAction: 'none',
opacity: opacity:
mouseState.dragging && mouseState.dragging &&
activeDrawField === drawnFieldIndex isActiveDrawnField(
fileIndex,
pageIndex,
drawnFieldIndex
)
? 0.8 ? 0.8
: 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,
fileIndex,
pageIndex,
drawnFieldIndex
)
} }
onPointerMove={(event) => { onPointerMove={(event) => {
handleResizePointerMove(event, drawnField, page.width) handleResizePointerMove(event, drawnField, page.width)
@ -417,7 +454,11 @@ export const DrawPDFFields = (props: Props) => {
style={{ style={{
background: background:
mouseState.resizing && mouseState.resizing &&
activeDrawField === drawnFieldIndex isActiveDrawnField(
fileIndex,
pageIndex,
drawnFieldIndex
)
? 'var(--primary-main)' ? 'var(--primary-main)'
: undefined : undefined
}} }}
@ -435,56 +476,59 @@ export const DrawPDFFields = (props: Props) => {
> >
<Close fontSize="small" /> <Close fontSize="small" />
</span> </span>
<div {!isActiveDrawnField(
onPointerDown={handleUserSelectPointerDown} fileIndex,
className={styles.userSelect} pageIndex,
> drawnFieldIndex
<FormControl fullWidth size="small"> ) &&
<InputLabel id="counterparts">Counterpart</InputLabel> !!drawnField.counterpart && (
<Select <div className={styles.counterpartAvatar}>
value={drawnField.counterpart} <UserAvatar
onChange={(event) => { pubkey={npubToHex(drawnField.counterpart)!}
drawnField.counterpart = event.target.value />
refreshPdfFiles() </div>
}} )}
labelId="counterparts" {isActiveDrawnField(
label="Counterparts" fileIndex,
sx={{ pageIndex,
background: 'white' drawnFieldIndex
}} ) && (
renderValue={(value) => renderCounterpartValue(value)} <div
> onPointerDown={handleUserSelectPointerDown}
{users className={styles.userSelect}
.filter((u) => u.role === UserRole.signer) >
.map((user, index) => { <FormControl fullWidth size="small">
const npub = hexToNpub(user.pubkey) <InputLabel id="counterparts">Counterpart</InputLabel>
let displayValue = truncate(npub, { <Select
length: 16 value={getAvailableSigner(drawnField.counterpart)}
}) onChange={(event) => {
drawnField.counterpart = event.target.value
const metadata = props.metadata[user.pubkey] setLastSigner(event.target.value)
refreshPdfFiles()
if (metadata) { }}
displayValue = truncate( labelId="counterparts"
metadata.name || label="Counterparts"
metadata.display_name || sx={{
metadata.username || background: 'white'
npub, }}
{ renderValue={(value) =>
length: 16 renderCounterpartValue(value)
} }
) >
} {signers.map((signer, index) => {
const npub = hexToNpub(signer.pubkey)
const metadata = props.metadata[signer.pubkey]
const displayValue = getProfileUsername(
npub,
metadata
)
return ( return (
<MenuItem <MenuItem key={index} value={npub}>
key={index}
value={hexToNpub(user.pubkey)}
>
<ListItemIcon> <ListItemIcon>
<AvatarIconButton <AvatarIconButton
src={metadata?.picture} src={metadata?.picture}
hexKey={user.pubkey} hexKey={signer.pubkey}
aria-label={`account of user ${displayValue}`} aria-label={`account of user ${displayValue}`}
color="inherit" color="inherit"
sx={{ sx={{
@ -500,9 +544,10 @@ export const DrawPDFFields = (props: Props) => {
</MenuItem> </MenuItem>
) )
})} })}
</Select> </Select>
</FormControl> </FormControl>
</div> </div>
)}
</div> </div>
) )
})} })}
@ -513,28 +558,19 @@ export const DrawPDFFields = (props: Props) => {
) )
} }
const renderCounterpartValue = (value: string) => { const renderCounterpartValue = (npub: string) => {
const user = users.find((u) => u.pubkey === npubToHex(value)) let displayValue = _.truncate(npub, { length: 16 })
if (user) {
let displayValue = truncate(value, {
length: 16
})
const metadata = props.metadata[user.pubkey] const signer = signers.find((u) => u.pubkey === npubToHex(npub))
if (signer) {
const metadata = props.metadata[signer.pubkey]
displayValue = getProfileUsername(npub, metadata)
if (metadata) {
displayValue = truncate(
metadata.name || metadata.display_name || metadata.username || value,
{
length: 16
}
)
}
return ( return (
<> <div className={styles.counterpartSelectValue}>
<AvatarIconButton <AvatarIconButton
src={props.metadata[user.pubkey]?.picture} src={props.metadata[signer.pubkey]?.picture}
hexKey={npubToHex(user.pubkey) || undefined} hexKey={signer.pubkey || undefined}
sx={{ sx={{
padding: 0, padding: 0,
marginRight: '6px', marginRight: '6px',
@ -545,19 +581,11 @@ export const DrawPDFFields = (props: Props) => {
}} }}
/> />
{displayValue} {displayValue}
</> </div>
) )
} }
return value return displayValue
}
if (parsingPdf) {
return <LoadingSpinner variant="small" />
}
if (!sigitFiles.length) {
return ''
} }
return ( return (
@ -578,7 +606,7 @@ export const DrawPDFFields = (props: Props) => {
<ExtensionFileBox extension={file.extension} /> <ExtensionFileBox extension={file.extension} />
)} )}
</div> </div>
{i < selectedFiles.length - 1 && <FileDivider />} {i < sigitFiles.length - 1 && <FileDivider />}
</React.Fragment> </React.Fragment>
) )
})} })}

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 {
@ -78,3 +84,14 @@
padding: 5px 0; padding: 5px 0;
} }
} }
.counterpartSelectValue {
display: flex;
}
.counterpartAvatar {
img {
width: 21px;
height: 21px;
}
}

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

@ -33,7 +33,8 @@ const PdfPageItem = ({
useEffect(() => { useEffect(() => {
if (selectedMark !== null && !!markRefs.current[selectedMark.id]) { if (selectedMark !== null && !!markRefs.current[selectedMark.id]) {
markRefs.current[selectedMark.id]?.scrollIntoView({ markRefs.current[selectedMark.id]?.scrollIntoView({
behavior: 'smooth' behavior: 'smooth',
block: 'center'
}) })
} }
}, [selectedMark]) }, [selectedMark])

View File

@ -5,7 +5,7 @@ import { AvatarIconButton } from '../UserAvatarIconButton'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useProfileMetadata } from '../../hooks/useProfileMetadata' import { useProfileMetadata } from '../../hooks/useProfileMetadata'
import { Tooltip } from '@mui/material' import { Tooltip } from '@mui/material'
import { shorten } from '../../utils' import { getProfileUsername } from '../../utils'
import { TooltipChild } from '../TooltipChild' import { TooltipChild } from '../TooltipChild'
interface UserAvatarProps { interface UserAvatarProps {
@ -22,7 +22,7 @@ export const UserAvatar = ({
isNameVisible = false isNameVisible = false
}: UserAvatarProps) => { }: UserAvatarProps) => {
const profile = useProfileMetadata(pubkey) const profile = useProfileMetadata(pubkey)
const name = profile?.display_name || profile?.name || shorten(pubkey) const name = getProfileUsername(pubkey, profile)
const image = profile?.picture const image = profile?.picture
return ( return (

View File

@ -42,15 +42,22 @@
.sides { .sides {
@media only screen and (min-width: 768px) { @media only screen and (min-width: 768px) {
position: sticky; position: sticky;
top: $header-height + $body-vertical-padding; top: $body-vertical-padding;
} }
> :first-child { > :first-child {
// We want to keep header on smaller devices at all times
max-height: calc( max-height: calc(
100dvh - $header-height - $body-vertical-padding * 2 - $tabs-height 100dvh - $header-height - $body-vertical-padding * 2 - $tabs-height
); );
@media only screen and (min-width: 768px) {
max-height: calc(100dvh - $body-vertical-padding * 2);
}
} }
} }
// Adjust the content scroll on smaller screens
// Make sure only the inner tab is scrolling
.scrollAdjust { .scrollAdjust {
@media only screen and (max-width: 767px) { @media only screen and (max-width: 767px) {
max-height: calc( max-height: calc(

View File

@ -3,5 +3,5 @@
.main { .main {
flex-grow: 1; flex-grow: 1;
padding: $header-height + $body-vertical-padding 0 $body-vertical-padding 0; padding: $body-vertical-padding 0 $body-vertical-padding 0;
} }

View File

@ -38,46 +38,31 @@ import {
sendNotification, sendNotification,
signEventForMetaFile, signEventForMetaFile,
updateUsersAppData, updateUsersAppData,
uploadToFileStorage uploadToFileStorage,
DEFAULT_TOOLBOX,
settleAllFullfilfedPromises
} 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
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { SigitFile } from '../../utils/file.ts' import { getSigitFile, SigitFile } from '../../utils/file.ts'
import _ from 'lodash'
export const CreatePage = () => { export const CreatePage = () => {
const navigate = useNavigate() const navigate = useNavigate()
@ -123,118 +108,31 @@ export const CreatePage = () => {
{} {}
) )
const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([]) const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([])
const [parsingPdf, setIsParsing] = useState<boolean>(false)
useEffect(() => {
if (selectedFiles) {
/**
* Reads the binary files and converts to internal file type
* and sets to a state (adds images if it's a PDF)
*/
const parsePages = async () => {
const files = await settleAllFullfilfedPromises(
selectedFiles,
getSigitFile
)
setDrawnFiles(files)
}
setIsParsing(true)
parsePages().finally(() => {
setIsParsing(false)
})
}
}, [selectedFiles])
const [selectedTool, setSelectedTool] = useState<DrawTool>() const [selectedTool, setSelectedTool] = useState<DrawTool>()
const [toolbox] = useState<DrawTool[]>([
{
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
@ -408,6 +306,19 @@ export const CreatePage = () => {
const handleRemoveUser = (pubkey: string) => { const handleRemoveUser = (pubkey: string) => {
setUsers((prev) => prev.filter((user) => user.pubkey !== pubkey)) setUsers((prev) => prev.filter((user) => user.pubkey !== pubkey))
// Set counterpart to ''
const drawnFilesCopy = _.cloneDeep(drawnFiles)
drawnFilesCopy.forEach((s) => {
s.pages?.forEach((p) => {
p.drawnFields.forEach((d) => {
if (d.counterpart === hexToNpub(pubkey)) {
d.counterpart = ''
}
})
})
})
setDrawnFiles(drawnFilesCopy)
} }
/** /**
@ -427,7 +338,15 @@ export const CreatePage = () => {
const handleSelectFiles = (event: React.ChangeEvent<HTMLInputElement>) => { const handleSelectFiles = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files) { if (event.target.files) {
setSelectedFiles(Array.from(event.target.files)) // Get the uploaded files
const files = Array.from(event.target.files)
// Remove duplicates based on the file.name
setSelectedFiles((p) =>
[...p, ...files].filter(
(file, i, array) => i === array.findIndex((t) => t.name === file.name)
)
)
} }
} }
@ -842,10 +761,6 @@ export const CreatePage = () => {
} }
} }
const onDrawFieldsChange = (sigitFiles: SigitFile[]) => {
setDrawnFiles(sigitFiles)
}
if (authUrl) { if (authUrl) {
return ( return (
<iframe <iframe
@ -959,28 +874,36 @@ export const CreatePage = () => {
</div> </div>
<div className={`${styles.paperGroup} ${styles.toolbox}`}> <div className={`${styles.paperGroup} ${styles.toolbox}`}>
{toolbox.map((drawTool: DrawTool, index: number) => { {DEFAULT_TOOLBOX.filter((drawTool) => !drawTool.isHidden).map(
return ( (drawTool: DrawTool, index: number) => {
<div return (
key={index} <div
{...(drawTool.active && { key={index}
onClick: () => handleToolSelect(drawTool) {...(!drawTool.isComingSoon && {
})} onClick: () => handleToolSelect(drawTool)
className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''} ${!drawTool.active ? styles.comingSoon : ''} })}
className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''} ${drawTool.isComingSoon ? styles.comingSoon : ''}
`} `}
> >
<FontAwesomeIcon fontSize={'15px'} icon={drawTool.icon} /> <FontAwesomeIcon
{drawTool.label} fontSize={'15px'}
{drawTool.active ? ( icon={drawTool.icon}
<FontAwesomeIcon fontSize={'15px'} icon={faEllipsis} /> />
) : ( {drawTool.label}
<span className={styles.comingSoonPlaceholder}> {!drawTool.isComingSoon ? (
Coming soon <FontAwesomeIcon
</span> fontSize={'15px'}
)} icon={faEllipsis}
</div> />
) ) : (
})} <span className={styles.comingSoonPlaceholder}>
Coming soon
</span>
)}
</div>
)
}
)}
</div> </div>
<Button onClick={handleCreate} variant="contained"> <Button onClick={handleCreate} variant="contained">
@ -996,13 +919,17 @@ export const CreatePage = () => {
centerIcon={faFile} centerIcon={faFile}
rightIcon={faToolbox} rightIcon={faToolbox}
> >
<DrawPDFFields {parsingPdf ? (
metadata={metadata} <LoadingSpinner variant="small" />
users={users} ) : (
selectedFiles={selectedFiles} <DrawPDFFields
onDrawFieldsChange={onDrawFieldsChange} users={users}
selectedTool={selectedTool} metadata={metadata}
/> selectedTool={selectedTool}
sigitFiles={drawnFiles}
setSigitFiles={setDrawnFiles}
/>
)}
</StickySideColumns> </StickySideColumns>
</Container> </Container>
</> </>

View File

@ -1,7 +1,6 @@
import ContentCopyIcon from '@mui/icons-material/ContentCopy' import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import EditIcon from '@mui/icons-material/Edit' import EditIcon from '@mui/icons-material/Edit'
import { Box, IconButton, SxProps, Theme, Typography } from '@mui/material' import { Box, IconButton, SxProps, Theme, Typography } from '@mui/material'
import { truncate } from 'lodash'
import { Event, VerifiedEvent, kinds, nip19 } from 'nostr-tools' import { Event, VerifiedEvent, kinds, nip19 } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
@ -14,6 +13,7 @@ import { State } from '../../store/rootReducer'
import { NostrJoiningBlock, ProfileMetadata } from '../../types' import { NostrJoiningBlock, ProfileMetadata } from '../../types'
import { import {
getNostrJoiningBlockNumber, getNostrJoiningBlockNumber,
getProfileUsername,
getRoboHashPicture, getRoboHashPicture,
hexToNpub, hexToNpub,
shorten shorten
@ -42,15 +42,7 @@ export const ProfilePage = () => {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [loadingSpinnerDesc] = useState('Fetching metadata') const [loadingSpinnerDesc] = useState('Fetching metadata')
const profileName = const profileName = pubkey && getProfileUsername(pubkey, profileMetadata)
pubkey &&
profileMetadata &&
truncate(
profileMetadata.display_name || profileMetadata.name || hexToNpub(pubkey),
{
length: 16
}
)
useEffect(() => { useEffect(() => {
if (npub) { if (npub) {

View File

@ -233,6 +233,7 @@ export const RelaysPage = () => {
))} ))}
</Box> </Box>
)} )}
<Footer />
</Container> </Container>
) )
} }
@ -428,7 +429,6 @@ const RelayItem = ({
)} )}
</List> </List>
</Box> </Box>
<Footer />
</> </>
) )
} }

View File

@ -22,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,
@ -399,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)

View File

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

View File

@ -31,7 +31,10 @@ export interface DrawTool {
icon: IconDefinition icon: IconDefinition
defaultValue?: string defaultValue?: string
selected?: boolean selected?: boolean
active?: boolean /** show or hide the toolbox item */
isHidden?: boolean
/** show or hide "coming soon" message on the toolbox item */
isComingSoon?: boolean
} }
export enum MarkType { export enum MarkType {

View File

@ -1,6 +1,7 @@
export interface ProfileMetadata { export interface ProfileMetadata {
name?: string name?: string
display_name?: string display_name?: string
/** @deprecated use name instead */
username?: string username?: string
picture?: string picture?: string
banner?: string banner?: string

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

@ -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 { DrawTool, MarkType } from '../types/drawing.ts'
import {
faT,
faSignature,
faBriefcase,
faIdCard,
faClock,
fa1,
faCalendarDays,
faCheckDouble,
faCircleDot,
faCreditCard,
faHeading,
faImage,
faPaperclip,
faPhone,
faSquareCaretDown,
faSquareCheck,
faStamp,
faTableCellsLarge
} 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,120 @@ 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: DrawTool[] = [
{
identifier: MarkType.FULLNAME,
icon: faIdCard,
label: 'Full Name',
isComingSoon: true
},
{
identifier: MarkType.JOBTITLE,
icon: faBriefcase,
label: 'Job Title',
isComingSoon: true
},
{
identifier: MarkType.SIGNATURE,
icon: faSignature,
label: 'Signature',
isComingSoon: true
},
{
identifier: MarkType.DATETIME,
icon: faClock,
label: 'Date Time',
isComingSoon: true
},
{
identifier: MarkType.TEXT,
icon: faT,
label: 'Text'
},
{
identifier: MarkType.NUMBER,
icon: fa1,
label: 'Number',
isComingSoon: true
},
{
identifier: MarkType.INITIALS,
icon: faHeading,
label: 'Initials',
isHidden: true
},
{
identifier: MarkType.DATE,
icon: faCalendarDays,
label: 'Date',
isHidden: true
},
{
identifier: MarkType.IMAGES,
icon: faImage,
label: 'Images',
isHidden: true
},
{
identifier: MarkType.CHECKBOX,
icon: faSquareCheck,
label: 'Checkbox',
isHidden: true
},
{
identifier: MarkType.MULTIPLE,
icon: faCheckDouble,
label: 'Multiple',
isHidden: true
},
{
identifier: MarkType.FILE,
icon: faPaperclip,
label: 'File',
isHidden: true
},
{
identifier: MarkType.RADIO,
icon: faCircleDot,
label: 'Radio',
isHidden: true
},
{
identifier: MarkType.SELECT,
icon: faSquareCaretDown,
label: 'Select',
isHidden: true
},
{
identifier: MarkType.CELLS,
icon: faTableCellsLarge,
label: 'Cells',
isHidden: true
},
{
identifier: MarkType.STAMP,
icon: faStamp,
label: 'Stamp',
isHidden: true
},
{
identifier: MarkType.PAYMENT,
icon: faCreditCard,
label: 'Payment',
isHidden: true
},
{
identifier: MarkType.PHONE,
icon: faPhone,
label: 'Phone',
isHidden: true
}
]
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

@ -1,6 +1,6 @@
import { bytesToHex, hexToBytes } from '@noble/hashes/utils' import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
import axios from 'axios' import axios from 'axios'
import _ from 'lodash' import _, { truncate } from 'lodash'
import { import {
Event, Event,
EventTemplate, EventTemplate,
@ -30,11 +30,12 @@ import {
import { AuthState, Keys } from '../store/auth/types' import { AuthState, Keys } from '../store/auth/types'
import { RelaysState } from '../store/relays/types' import { RelaysState } from '../store/relays/types'
import store from '../store/store' import store from '../store/store'
import { Meta, SignedEvent, UserAppData } from '../types' import { Meta, ProfileMetadata, SignedEvent, UserAppData } from '../types'
import { getDefaultRelayMap } from './relays' 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
@ -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
} }
@ -970,3 +974,16 @@ export const sendNotification = async (receiver: string, meta: Meta) => {
throw err throw err
}) })
} }
/**
* Show user's name, first available in order: display_name, name, or npub as fallback
* @param npub User identifier, it can be either pubkey or npub1 (we only show npub)
* @param profile User profile
*/
export const getProfileUsername = (
npub: `npub1${string}` | string,
profile?: ProfileMetadata
) =>
truncate(profile?.display_name || profile?.name || hexToNpub(npub), {
length: 16
})

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 < pages.length; i++) {
if (marksPerPage && Object.hasOwn(marksPerPage, i)) {
for (let i = 0; i < pdf.numPages; i++) { marksPerPage[i]?.forEach((mark) =>
const page = await pdf.getPage(i + 1) drawMarkText(mark, pages[i], robotoFont)
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())
} }
} }
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,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 * 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,
y: 0,
width: pngImage.width,
height: pngImage.height
})
}
const pdfBytes = await pdfDoc.save() // Adjust y-coordinate for the text, drawText's y is the baseline for the font
return new Blob([pdfBytes], { type: 'application/pdf' }) // 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
}